<?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[ Magnus Rødseth - 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[ Magnus Rødseth - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Tue, 19 May 2026 20:23:58 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/magnusrodseth/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Complete SaaS Payment Flow with Stripe, Webhooks, and Email Notifications ]]>
                </title>
                <description>
                    <![CDATA[ Most Stripe tutorials end at the checkout page. The customer clicks "Pay," Stripe processes the charge, and the tutorial congratulates you on integrating payments. But that's only the first 10% of a r ]]>
                </description>
                <link>https://www.freecodecamp.org/news/saas-payment-flow-stripe-webhooks-email/</link>
                <guid isPermaLink="false">69fe0830f239332df4de5722</guid>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Software Engineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Magnus Rødseth ]]>
                </dc:creator>
                <pubDate>Fri, 08 May 2026 15:58:40 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/de7d5c4d-062c-4879-892c-4486c7c461af.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most Stripe tutorials end at the checkout page. The customer clicks "Pay," Stripe processes the charge, and the tutorial congratulates you on integrating payments.</p>
<p>But that's only the first 10% of a real payment system.</p>
<p>What happens after the customer pays? You need to record the purchase in your database, send a confirmation email, and grant product access (a GitHub repo invitation, an API key, a license file). You need to notify yourself as the admin. You need to handle refunds two weeks later and send recovery emails when someone abandons checkout.</p>
<p>This is the complete payment lifecycle, and it's where most SaaS applications break.</p>
<p>This article walks you through building the entire flow, from the "Buy" button to the "Welcome" email and everything in between. Every code example comes from a production application processing real payments. You'll see how to design the database schema, create Stripe products, build the checkout flow, process purchases reliably, handle refunds, recover abandoned carts, and send transactional emails.</p>
<p>Here is what you'll learn:</p>
<ul>
<li><p>How to design a database schema that tracks every stage of a purchase</p>
</li>
<li><p>How to create Stripe products and prices programmatically</p>
</li>
<li><p>How to build a checkout flow with success/cancel handling</p>
</li>
<li><p>How to process webhooks securely with signature verification</p>
</li>
<li><p>How to split post-payment processing into durable, independently retried steps</p>
</li>
<li><p>How to handle full and partial refunds with automatic access revocation</p>
</li>
<li><p>How to recover revenue from abandoned checkouts</p>
</li>
<li><p>How to build transactional email templates with React Email and Resend</p>
</li>
<li><p>How to test the entire flow locally with Stripe CLI and Inngest</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-to-design-the-payment-database-schema">How to Design the Payment Database Schema</a></p>
</li>
<li><p><a href="#heading-how-to-create-stripe-products-and-prices">How to Create Stripe Products and Prices</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-checkout-flow">How to Build the Checkout Flow</a></p>
</li>
<li><p><a href="#heading-how-to-handle-webhooks-securely">How to Handle Webhooks Securely</a></p>
</li>
<li><p><a href="#heading-how-to-process-purchases-with-durable-background-jobs">How to Process Purchases with Durable Background Jobs</a></p>
</li>
<li><p><a href="#heading-how-to-handle-refunds">How to Handle Refunds</a></p>
</li>
<li><p><a href="#heading-how-to-recover-abandoned-checkouts">How to Recover Abandoned Checkouts</a></p>
</li>
<li><p><a href="#heading-how-to-send-transactional-emails-with-react-email">How to Send Transactional Emails with React Email</a></p>
</li>
<li><p><a href="#heading-how-to-test-the-complete-flow-locally">How to Test the Complete Flow Locally</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should be familiar with:</p>
<ul>
<li><p>TypeScript and Node.js</p>
</li>
<li><p>SQL databases (the examples use PostgreSQL)</p>
</li>
<li><p>React (for email templates)</p>
</li>
<li><p>Basic understanding of webhooks</p>
</li>
</ul>
<p>You don't need prior experience with any of the specific libraries. This handbook explains each one as it appears.</p>
<h3 id="heading-what-you-need-installed">What You Need Installed</h3>
<p>Install these packages to run the code examples:</p>
<pre><code class="language-bash">bun add stripe drizzle-orm @neondatabase/serverless inngest resend @react-email/components
</code></pre>
<p>You'll also need:</p>
<ul>
<li><p>A <a href="https://dashboard.stripe.com/register">Stripe account</a> (test mode is fine)</p>
</li>
<li><p>A <a href="https://neon.tech">Neon</a> PostgreSQL database (or any PostgreSQL instance)</p>
</li>
<li><p>A <a href="https://resend.com">Resend</a> account for sending emails</p>
</li>
<li><p>The <a href="https://stripe.com/docs/stripe-cli">Stripe CLI</a> for local webhook testing</p>
</li>
</ul>
<h3 id="heading-environment-variables">Environment Variables</h3>
<p>Set up these environment variables in your <code>.env</code> file:</p>
<pre><code class="language-bash"># Database
DATABASE_URL=postgresql://...

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

let stripeClient: Stripe | null = null;

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

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

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

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

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

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

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

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

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

    const { sessionId } = body;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        const foundPurchase = purchaseResult[0];

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    let accessRevoked = false;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

let resendClient: Resend | null = null;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

interface RepoAccessGrantedEmailProps {
  repoUrl: string;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        const foundPurchase = purchaseResult[0];

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    const isFullRefund = amountRefunded &gt;= originalAmount;

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

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

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

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

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

    let accessRevoked = false;

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

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

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

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

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

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

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

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

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

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

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

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

    return { success: true, customerEmail };
  }
);
</code></pre>
<p>The <code>step.sleep("wait-before-recovery-email", "1h")</code> line is the key. This pauses the function for one hour without consuming any compute resources.</p>
<p>Inngest handles the scheduling internally. After one hour, the function resumes and sends the email.</p>
<p>Without durable execution, you would need a cron job that queries a database for expired sessions, or a delayed job queue with Redis, or a <code>setTimeout</code> that gets lost when your server restarts. The <code>step.sleep()</code> approach is simpler, more readable, and more reliable.</p>
<p>There's also a guard at the top of the function. If Stripe doesn't have a customer email for the session (the customer closed the checkout before entering their email), the function returns early. There's no point scheduling a recovery email with no address to send it to.</p>
<p>This pattern scales to more complex recovery flows. You could add a second <code>step.sleep()</code> and send a follow-up recovery email three days later if the customer still hasn't purchased. You could check if the customer has since completed a purchase (by querying the database in a <code>step.run()</code>) and skip the email if they have.</p>
<p>Each additional step is one more <code>step.run()</code> or <code>step.sleep()</code> call. The function reads like a script describing your business logic, not a tangle of cron jobs and database flags.</p>
<h2 id="heading-how-to-test-webhook-handlers-locally">How to Test Webhook Handlers Locally</h2>
<p>Local testing is one of the biggest pain points with Stripe webhooks. You need Stripe to send events to your local machine, and you need your background job system running to process them. Here's the setup.</p>
<h3 id="heading-how-to-forward-stripe-events-locally">How to Forward Stripe Events Locally</h3>
<p>Install the <a href="https://stripe.com/docs/stripe-cli">Stripe CLI</a> and forward webhook events to your local server:</p>
<pre><code class="language-bash">stripe listen --forward-to localhost:3000/api/payments/webhook
</code></pre>
<p>The CLI prints a webhook signing secret (starting with <code>whsec_</code>). Set this as your <code>STRIPE_WEBHOOK_SECRET</code> environment variable for local development.</p>
<p>You can trigger test events directly:</p>
<pre><code class="language-bash">stripe trigger checkout.session.completed
stripe trigger charge.refunded
stripe trigger checkout.session.expired
</code></pre>
<h3 id="heading-how-to-run-the-inngest-dev-server">How to Run the Inngest Dev Server</h3>
<p>Inngest provides a local dev server that shows you every function execution, every step, and every retry in real time:</p>
<pre><code class="language-bash">npx inngest-cli@latest dev -u http://localhost:3000/api/inngest
</code></pre>
<p>The <code>-u</code> flag tells the Inngest dev server where your application is running so it can discover your functions. Open <code>http://localhost:8288</code> in your browser to see the Inngest dashboard.</p>
<h3 id="heading-how-to-watch-step-execution">How to Watch Step Execution</h3>
<p>The Inngest dev dashboard is where the durable execution pattern really clicks. When you trigger a Stripe event, you can see:</p>
<ol>
<li><p>The event arriving in the "Events" tab.</p>
</li>
<li><p>The function triggering in the "Runs" tab.</p>
</li>
<li><p>Each step executing one by one, with its input, output, and duration.</p>
</li>
<li><p>If a step fails, you see the error and the retry attempt.</p>
</li>
</ol>
<p>This visibility is something you don't get with inline webhook handlers. When a customer reports "I paid but didn't get access," you can look up the function run in the Inngest dashboard and see exactly which step failed and why. That kind of observability is invaluable in production.</p>
<h3 id="heading-how-to-simulate-failures">How to Simulate Failures</h3>
<p>To test the retry behavior, you can intentionally make a step fail. For example, temporarily throw an error in the "add-github-collaborator" step:</p>
<pre><code class="language-typescript">const collaboratorResult = await step.run(
  "add-github-collaborator",
  async () =&gt; {
    throw new Error("Simulated GitHub API failure");
  }
);
</code></pre>
<p>In the Inngest dashboard, you'll see:</p>
<ul>
<li><p>Steps 1 through 4 succeed and their results are cached.</p>
</li>
<li><p>Step 5 fails and is retried according to the retry policy.</p>
</li>
<li><p>Steps 6 through 9 remain pending until step 5 succeeds.</p>
</li>
</ul>
<p>Remove the thrown error, and on the next retry, step 5 succeeds. Steps 6 through 9 then execute in sequence, while steps 1 through 4 aren't re-executed. This is the checkpoint behavior in action.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The pattern for reliable Stripe webhooks comes down to one principle: <strong>separate receiving from processing.</strong></p>
<p>Your webhook endpoint validates the Stripe signature and sends a typed event to a background job system. That's all it does. The processing happens in a durable function where each step is individually checkpointed and retried.</p>
<p>Here's what this gives you:</p>
<ul>
<li><p><strong>No duplicate emails:</strong> A step that already succeeded doesn't re-run.</p>
</li>
<li><p><strong>No partial state:</strong> If step 5 fails, steps 1 through 4 are preserved and step 5 retries independently.</p>
</li>
<li><p><strong>Full observability:</strong> You can see exactly which step failed and why, for every function run.</p>
</li>
<li><p><strong>Built-in delayed execution:</strong> <code>step.sleep()</code> handles recovery emails and follow-up sequences without cron jobs.</p>
</li>
<li><p><strong>Composable workflows:</strong> One function can trigger another via events, creating chains like purchase completion leading to a 30-day follow-up sequence.</p>
</li>
</ul>
<p>This pattern isn't limited to Stripe. Any multi-step webhook processing benefits from durable execution: GitHub webhooks that trigger CI pipelines, Resend webhooks that track email delivery, or calendar webhooks that sync across services.</p>
<p>The principle is the same: Validate. Enqueue. Process durably.</p>
<p>I've used this pattern in production for <a href="https://eden-stack.com?utm_source=freecodecamp&amp;utm_medium=article&amp;utm_campaign=stripe-webhooks-background-jobs">Eden Stack</a>, where the purchase flow handles everything from payment confirmation to GitHub repository access grants to multi-week email sequences. The 9-step purchase function has processed every payment without a single missed step or duplicate email.</p>
<p>If you're building a SaaS with Stripe, start with the webhook endpoint pattern from this article. Keep the endpoint thin and move the processing into durable steps. You'll save yourself from the 3 AM debugging session when a customer says "I paid but nothing happened."</p>
<p>If you want the complete Stripe webhook and Inngest integration pre-built with purchase flows, refund handling, and follow-up email sequences ready to go, <a href="https://eden-stack.com?utm_source=freecodecamp&amp;utm_medium=article&amp;utm_campaign=stripe-webhooks-background-jobs">Eden Stack</a> includes everything from this article alongside 30+ additional production-tested patterns.</p>
<p><em>Magnus Rodseth builds AI-native applications and is the creator of</em> <a href="https://eden-stack.com?utm_source=freecodecamp&amp;utm_medium=article&amp;utm_campaign=stripe-webhooks-background-jobs"><em>Eden Stack</em></a><em>, a production-ready starter kit with 30+ Claude skills encoding production patterns for AI-native SaaS development.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Full-Stack SaaS App with TanStack Start, Elysia, and Neon ]]>
                </title>
                <description>
                    <![CDATA[ Most full-stack React tutorials stop at "Hello World." They show you how to render a component, maybe fetch some data, and call it a day. But when you sit down to build a real SaaS application, you im ]]>
                </description>
                <link>https://www.freecodecamp.org/news/full-stack-saas-tanstack-start-elysia-neon/</link>
                <guid isPermaLink="false">69ce8f9b0ff860b6defe701d</guid>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Magnus Rødseth ]]>
                </dc:creator>
                <pubDate>Thu, 02 Apr 2026 15:47:39 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/ae3ac13a-e6b4-4498-aa32-ebd8c60c44a2.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most full-stack React tutorials stop at "Hello World." They show you how to render a component, maybe fetch some data, and call it a day.</p>
<p>But when you sit down to build a real SaaS application, you immediately hit a wall of unanswered questions. How do you structure your database? Where does authentication live? How do you make API calls type-safe? How do you handle payments without losing webhooks?</p>
<p>This handbook answers all of those questions. You'll build a production-ready SaaS application from scratch using TanStack Start, Elysia, Drizzle ORM, Neon PostgreSQL, Better Auth, Stripe, and Inngest.</p>
<p>By the end, you will have a deployed application with authentication, a type-safe API, database migrations, payment processing, and background jobs.</p>
<p>I chose this stack after building production applications with Next.js, Express, and Prisma. The combination of TanStack Start and Elysia with Eden Treaty gives you something rare: end-to-end type safety from your database schema to your React components, with zero code generation.</p>
<p>Change a column in your database, and TypeScript tells you everywhere that needs updating. That feedback loop changes how you build software.</p>
<p>Here's what you'll learn:</p>
<ul>
<li><p>How to set up a TanStack Start project with Vite and file-based routing</p>
</li>
<li><p>How to configure a PostgreSQL database with Drizzle ORM and Neon</p>
</li>
<li><p>How to build a type-safe API with Elysia embedded in your web app</p>
</li>
<li><p>How to connect your frontend to your API with Eden Treaty</p>
</li>
<li><p>How to add GitHub OAuth authentication with Better Auth</p>
</li>
<li><p>How to build complete features using a repeatable four-layer pattern</p>
</li>
<li><p>How to process payments with Stripe webhooks</p>
</li>
<li><p>How to run reliable background jobs with Inngest</p>
</li>
<li><p>How to deploy everything to Vercel with Neon</p>
</li>
</ul>
<h3 id="heading-why-tanstack-start-instead-of-nextjs">Why TanStack Start Instead of Next.js?</h3>
<p>You might be wondering –&nbsp;why not just use Next.js? It's the default choice for full-stack React, and for good reason. Next.js pioneered server-side rendering, established conventions that shaped the React ecosystem, and has the largest community of any React framework.</p>
<p>But TanStack Start has three advantages that matter for this kind of project.</p>
<h4 id="heading-1-deployment-flexibility">1. Deployment flexibility</h4>
<p>TanStack Start compiles to standard JavaScript that runs anywhere: Node.js, Bun, Deno, Cloudflare Workers, AWS Lambda, or your own server. Next.js is notoriously difficult to self-host outside of Vercel.</p>
<p>If you search "Next.js Azure App Service container" or "Next.js ISR self-hosted," you'll find years of Stack Overflow questions about edge cases that only appear in production.</p>
<h4 id="heading-2-simpler-mental-model">2. Simpler mental model</h4>
<p>Next.js has grown complex: the App Router, React Server Components, Server Actions, partial prerendering, <code>cache()</code>, <code>unstable_cache()</code>, plus various rendering strategies.</p>
<p>TanStack Start uses full-document SSR with full hydration. There's no opaque server/client boundary confusion. The tradeoff is that you don't get RSC's granular streaming, but you gain clarity and predictability.</p>
<h4 id="heading-3-end-to-end-type-safety">3. End-to-end type safety</h4>
<p>Combined with Elysia and Eden Treaty, TanStack Start gives you compile-time type inference from your database to your UI. No code generation steps. No schema files to keep in sync.</p>
<p>TanStack Router itself provides fully type-safe routing with inferred path params, search params, and loader data.</p>
<p>This is a handbook, so it goes deep. Set aside a few hours, open your editor, and let's build something real.</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-how-to-set-up-the-project">How to Set Up the Project</a></p>
</li>
<li><p><a href="#heading-how-to-configure-the-database-with-drizzle-and-neon">How to Configure the Database with Drizzle and Neon</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-api-with-elysia">How to Build the API with Elysia</a></p>
</li>
<li><p><a href="#heading-how-to-add-type-safe-api-calls-with-eden-treaty">How to Add Type-Safe API Calls with Eden Treaty</a></p>
</li>
<li><p><a href="#heading-how-to-add-authentication-with-better-auth">How to Add Authentication with Better Auth</a></p>
</li>
<li><p><a href="#heading-how-to-build-a-complete-feature-the-four-layer-pattern">How to Build a Complete Feature (The Four-Layer Pattern)</a></p>
</li>
<li><p><a href="#heading-how-to-add-payments-with-stripe">How to Add Payments with Stripe</a></p>
</li>
<li><p><a href="#heading-how-to-add-background-jobs-with-inngest">How to Add Background Jobs with Inngest</a></p>
</li>
<li><p><a href="#heading-how-to-deploy-to-vercel-with-neon">How to Deploy to Vercel with Neon</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, make sure you have the following installed:</p>
<ul>
<li><p><a href="https://bun.sh"><strong>Bun</strong></a> (v1.2 or later) for package management and running scripts</p>
</li>
<li><p><a href="https://www.docker.com/products/docker-desktop/"><strong>Docker</strong></a> for running PostgreSQL locally</p>
</li>
<li><p><a href="https://git-scm.com/"><strong>Git</strong></a> for version control</p>
</li>
<li><p>Basic knowledge of React and TypeScript</p>
</li>
</ul>
<p>You'll also need free accounts on these services:</p>
<ul>
<li><p><a href="https://neon.tech"><strong>Neon</strong></a> for your production PostgreSQL database</p>
</li>
<li><p><a href="https://vercel.com"><strong>Vercel</strong></a> for deployment</p>
</li>
<li><p><a href="https://github.com"><strong>GitHub</strong></a> for OAuth authentication (you will create an OAuth app)</p>
</li>
<li><p><a href="https://stripe.com"><strong>Stripe</strong></a> for payment processing (test mode is free)</p>
</li>
</ul>
<p>All of these services have generous free tiers. You won't need to pay anything to follow this tutorial.</p>
<p>You should also be comfortable reading TypeScript code. This handbook assumes you understand generics, type inference, and async/await. If you're new to TypeScript, the <a href="https://www.typescriptlang.org/docs/handbook/">official handbook</a> is a solid starting point.</p>
<h2 id="heading-how-to-set-up-the-project">How to Set Up the Project</h2>
<p>Start by creating a new TanStack Start project. TanStack provides a CLI that scaffolds a project with file-based routing, Vite, and server-side rendering out of the box.</p>
<pre><code class="language-bash">bunx @tanstack/cli@latest create my-saas
cd my-saas
bun install
</code></pre>
<p>The CLI will ask you a few questions. Choose React as your framework and accept the defaults for the rest.</p>
<p>You're using Bun as your package manager and runtime. Bun is significantly faster than npm for installing dependencies and running scripts. It also natively supports TypeScript execution, which means you can run <code>.ts</code> files directly without a compilation step.</p>
<p>If you prefer npm or pnpm, the commands are similar, but this tutorial uses Bun throughout.</p>
<h3 id="heading-how-to-understand-the-project-structure">How to Understand the Project Structure</h3>
<p>Before writing any code, let's look at how you'll organize this project. The key architectural decision is putting all library code under <code>src/lib/</code>. Each integration (database, auth, payments, and so on) gets its own directory with a clean public API through an <code>index.ts</code> file.</p>
<p>Here's the structure you'll build toward:</p>
<pre><code class="language-text">my-saas/
├── src/
│   ├── components/          # React components
│   ├── hooks/               # Custom React hooks
│   ├── lib/
│   │   ├── auth/            # Better Auth (server + client)
│   │   ├── db/              # Drizzle ORM + schema
│   │   ├── jobs/            # Inngest background jobs
│   │   └── payments/        # Stripe integration
│   ├── routes/              # TanStack file-based routing
│   ├── server/
│   │   ├── api.ts           # Elysia API definition
│   │   └── routes/          # API route modules
│   └── start.ts             # TanStack Start entry point
├── docker-compose.yml       # Local PostgreSQL + Neon proxy
├── drizzle.config.ts        # Drizzle Kit configuration
├── vite.config.ts           # Vite + TanStack Start config
└── package.json
</code></pre>
<p>Here's how all the pieces connect:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a694d8d4dc9b42434c218f/5bf61d3b-0587-445a-8be1-79f869aa554b.png" alt="Full-stack SaaS architecture diagram showing TanStack Start handling the frontend, connected to an embedded Elysia API server that integrates with Better Auth for authentication, Stripe for payments, and Inngest for background jobs, with Drizzle ORM providing type-safe database access to Neon PostgreSQL" style="display:block;margin:0 auto" width="5504" height="3072" loading="lazy">

<p>TanStack Start handles your frontend. It talks to an Elysia API server embedded in the same project. Elysia connects to three external services: Better Auth for authentication, Stripe for payments, and Inngest for background jobs. Below the API layer, Drizzle ORM provides type-safe database access to Neon PostgreSQL.</p>
<p>You'll build each layer one at a time, starting with the database.</p>
<p>This pattern keeps every integration isolated. When you need to change how authentication works, you go to <code>src/lib/auth/</code>. When you need to modify the database schema, you go to <code>src/lib/db/</code>. Nothing leaks across boundaries.</p>
<h3 id="heading-how-to-configure-vite">How to Configure Vite</h3>
<p>TanStack Start runs on Vite. Your <code>vite.config.ts</code> needs the TanStack Start plugin, the React plugin, and path resolution for the <code>@/</code> import alias:</p>
<pre><code class="language-typescript">// vite.config.ts
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import tsConfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  server: {
    port: 3000,
  },
  plugins: [
    tsConfigPaths({
      projects: ["./tsconfig.json"],
    }),
    tanstackStart(),
    viteReact(),
  ],
});
</code></pre>
<p>The <code>tsConfigPaths</code> plugin reads the <code>paths</code> setting from your <code>tsconfig.json</code>, so you can use <code>@/lib/db</code> instead of <code>../../lib/db</code> throughout your code.</p>
<p>Add this to your <code>tsconfig.json</code>:</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}
</code></pre>
<h3 id="heading-how-to-install-dependencies">How to Install Dependencies</h3>
<p>Install the core dependencies you'll need throughout this tutorial:</p>
<pre><code class="language-bash"># Framework and routing
bun add @tanstack/react-router @tanstack/react-start react react-dom

# API layer
bun add elysia @elysiajs/eden

# Database
bun add drizzle-orm @neondatabase/serverless ws
bun add -d drizzle-kit

# Authentication
bun add better-auth

# Payments
bun add stripe

# Background jobs
bun add inngest

# Build tools
bun add -d @vitejs/plugin-react vite vite-tsconfig-paths typescript
</code></pre>
<p>Now you have a working TanStack Start project with all the dependencies you'll need. Start the dev server to make sure everything works:</p>
<pre><code class="language-bash">bun run dev
</code></pre>
<p>Visit <code>http://localhost:3000</code> and you should see your app running.</p>
<h2 id="heading-how-to-configure-the-database-with-drizzle-and-neon">How to Configure the Database with Drizzle and Neon</h2>
<p>Every SaaS needs a database. You'll use Drizzle ORM with Neon PostgreSQL. Drizzle gives you type-safe database queries that look like SQL, and Neon gives you a serverless PostgreSQL database that scales to zero when you aren't using it.</p>
<h3 id="heading-why-drizzle-instead-of-prisma">Why Drizzle Instead of Prisma?</h3>
<p>If you have used an ORM in the TypeScript ecosystem before, it was probably Prisma. Prisma is excellent for many use cases, but it has a key limitation for this architecture: it uses code generation.</p>
<p>You write a <code>.prisma</code> schema file, run <code>prisma generate</code>, and Prisma generates a TypeScript client. That generation step adds friction to your development loop and creates artifacts you need to keep in sync.</p>
<p>Drizzle takes a different approach. Your schema is TypeScript. Your queries are TypeScript. Types are inferred at compile time without any generation step.</p>
<p>When you add a column to a table, the types update immediately. This fits perfectly with the rest of the stack, where types flow from Drizzle through Elysia to Eden Treaty without any intermediate steps.</p>
<p>Drizzle also produces SQL that looks like SQL. If you know PostgreSQL, you can read Drizzle queries. There is no Prisma-specific query language to learn.</p>
<h3 id="heading-how-to-set-up-local-postgresql-with-docker">How to Set Up Local PostgreSQL with Docker</h3>
<p>For local development, you'll run PostgreSQL in Docker with a Neon-compatible proxy. This lets you use the same Neon serverless driver locally that you'll use in production.</p>
<p>Create a <code>docker-compose.yml</code> at the project root:</p>
<pre><code class="language-yaml"># docker-compose.yml
services:
  postgres:
    image: postgres:17
    container_name: my-saas-postgres
    restart: unless-stopped
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: my_saas
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  neon-proxy:
    image: ghcr.io/timowilhelm/local-neon-http-proxy:main
    container_name: my-saas-neon-proxy
    restart: unless-stopped
    environment:
      - PG_CONNECTION_STRING=postgres://postgres:postgres@postgres:5432/my_saas
    ports:
      - "4444:4444"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  postgres_data:
</code></pre>
<p>The <code>neon-proxy</code> container is the important part. It translates HTTP requests into PostgreSQL wire protocol, which means your Neon serverless driver works locally without any code changes.</p>
<p>In production, Neon handles this translation on their infrastructure. Locally, you need this proxy to bridge the gap between the HTTP-based Neon driver and your plain PostgreSQL container.</p>
<p>The <code>healthcheck</code> on the PostgreSQL container ensures the proxy only starts after the database is ready. Without this, the proxy would try to connect to a database that's still initializing, causing connection errors on first startup.</p>
<p>Start the containers:</p>
<pre><code class="language-bash">docker compose up -d
</code></pre>
<h3 id="heading-how-to-define-your-schema">How to Define Your Schema</h3>
<p>Create the database client and schema. Start with <code>src/lib/db/index.ts</code> for the connection:</p>
<pre><code class="language-typescript">// src/lib/db/index.ts
import { neon, neonConfig } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import ws from "ws";

import * as schema from "./schema";

const isProduction = process.env.NODE_ENV === "production";
const LOCAL_DB_HOST = "db.localtest.me";

let connectionString = process.env.DATABASE_URL;

if (!connectionString) {
  throw new Error("DATABASE_URL environment variable is not set");
}

neonConfig.webSocketConstructor = ws;

if (!isProduction) {
  connectionString = `postgres://postgres:postgres@${LOCAL_DB_HOST}:5432/my_saas`;
  neonConfig.fetchEndpoint = (host) =&gt; {
    const [protocol, port] =
      host === LOCAL_DB_HOST ? ["http", 4444] : ["https", 443];
    return `\({protocol}://\){host}:${port}/sql`;
  };
  neonConfig.useSecureWebSocket = false;
  neonConfig.wsProxy = (host) =&gt;
    host === LOCAL_DB_HOST ? `\({host}:4444/v2` : `\){host}/v2`;
}

const client = neon(connectionString);
export const db = drizzle({ client, schema });

export * from "./schema";
</code></pre>
<p>The <code>db.localtest.me</code> hostname resolves to <code>127.0.0.1</code> and is the standard way to work with the local Neon proxy. In production, the Neon driver connects directly to your Neon database using the <code>DATABASE_URL</code> environment variable.</p>
<p>Now define your schema in <code>src/lib/db/schema.ts</code>. For a SaaS application, you need users, sessions, accounts (for OAuth), and a table for your core business entity. Here's a real production schema:</p>
<pre><code class="language-typescript">// src/lib/db/schema.ts
import {
  boolean,
  integer,
  pgEnum,
  pgTable,
  text,
  timestamp,
  varchar,
} from "drizzle-orm/pg-core";

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

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

export const sessions = pgTable("sessions", {
  id: text("id").primaryKey(),
  userId: text("user_id")
    .notNull()
    .references(() =&gt; users.id, { onDelete: "cascade" }),
  token: text("token").notNull().unique(),
  expiresAt: timestamp("expires_at").notNull(),
  ipAddress: text("ip_address"),
  userAgent: text("user_agent"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const accounts = pgTable("accounts", {
  id: text("id").primaryKey(),
  userId: text("user_id")
    .notNull()
    .references(() =&gt; users.id, { onDelete: "cascade" }),
  accountId: text("account_id").notNull(),
  providerId: text("provider_id").notNull(),
  accessToken: text("access_token"),
  refreshToken: text("refresh_token"),
  accessTokenExpiresAt: timestamp("access_token_expires_at"),
  refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
  scope: text("scope"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const verifications = pgTable("verifications", {
  id: text("id").primaryKey(),
  identifier: text("identifier").notNull(),
  value: text("value").notNull(),
  expiresAt: timestamp("expires_at").notNull(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

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

// Type exports for use in your application
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Purchase = typeof purchases.$inferSelect;
export type NewPurchase = typeof purchases.$inferInsert;
</code></pre>
<p>Push the schema to create the tables:</p>
<pre><code class="language-bash">bun run db:push
</code></pre>
<p>A few things to notice about this schema:</p>
<ol>
<li><p>The <code>users</code>, <code>sessions</code>, <code>accounts</code>, and <code>verifications</code> tables are required by Better Auth. You'll configure the auth library to use these tables in the next section.</p>
</li>
<li><p>The <code>purchases</code> table is your core business entity. It tracks Stripe checkout sessions and links them to users.</p>
</li>
<li><p>Type exports like <code>User</code> and <code>Purchase</code> give you inferred TypeScript types from your schema. You never define types manually. They come from the schema definition.</p>
</li>
<li><p>The <code>$defaultFn</code> on the <code>purchases.id</code> column generates a UUID automatically when you insert a row. The auth tables use text IDs because Better Auth generates its own IDs.</p>
</li>
</ol>
<h3 id="heading-how-to-configure-drizzle-kit">How to Configure Drizzle Kit</h3>
<p>Create <code>drizzle.config.ts</code> at the project root:</p>
<pre><code class="language-typescript">// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  dialect: "postgresql",
  schema: "./src/lib/db/schema.ts",
  out: "./drizzle",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  verbose: true,
  strict: true,
});
</code></pre>
<p>Add these scripts to your <code>package.json</code>:</p>
<pre><code class="language-json">{
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:push": "drizzle-kit push",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio"
  }
}
</code></pre>
<p>Now push your schema to the local database:</p>
<pre><code class="language-bash">bun run db:push
</code></pre>
<p>Drizzle Kit reads your schema file, compares it to the database, and applies any changes. For development, <code>db:push</code> is fast and convenient. For production, you'll use <code>db:generate</code> and <code>db:migrate</code> to create versioned SQL migration files.</p>
<p>You can open Drizzle Studio to inspect your database visually:</p>
<pre><code class="language-bash">bun run db:studio
</code></pre>
<p>This opens a web UI at <code>https://local.drizzle.studio</code> where you can browse tables, run queries, and inspect data.</p>
<h2 id="heading-how-to-build-the-api-with-elysia">How to Build the API with Elysia</h2>
<p>Here's where this stack gets interesting. Instead of running a separate API server, you embed Elysia directly inside TanStack Start. Both your web app and your API live in the same process, share the same types, and deploy as a single unit.</p>
<h3 id="heading-why-elysia-instead-of-express">Why Elysia Instead of Express?</h3>
<p>If you've built Node.js APIs before, you've probably used Express. It is 15 years old and has a massive ecosystem. But Express was designed before TypeScript, before async/await, and before developers expected type safety across the full stack.</p>
<p>Elysia takes a different approach. It was built for TypeScript from day one. Request bodies, response types, and path parameters are all inferred at compile time.</p>
<p>Combined with Eden Treaty (which you'll set up in the next section), your frontend gets full type safety when calling your API. No code generation. No OpenAPI schemas to keep in sync. Just TypeScript inference.</p>
<p>Elysia also includes built-in request validation using its <code>t</code> (TypeBox) schema builder:</p>
<pre><code class="language-typescript">import { Elysia, t } from "elysia";

new Elysia().post(
  "/users",
  ({ body }) =&gt; {
    // body is typed as { name: string, email: string }
    return createUser(body);
  },
  {
    body: t.Object({
      name: t.String(),
      email: t.String(),
    }),
  }
);
</code></pre>
<p>The schema validates at runtime and provides TypeScript types at compile time. One definition serves both purposes.</p>
<h3 id="heading-how-to-define-your-api">How to Define Your API</h3>
<p>Create <code>src/server/api.ts</code>. This is where all your API routes live:</p>
<pre><code class="language-typescript">// src/server/api.ts
import { Elysia, t } from "elysia";
import { eq } from "drizzle-orm";

import { auth } from "@/lib/auth";
import { db, purchases, users } from "@/lib/db";

export const api = new Elysia({ prefix: "/api" })
  .onRequest(({ request }) =&gt; {
    console.log(`[API] \({request.method} \){request.url}`);
  })
  .onError(({ code, error, path }) =&gt; {
    console.error(`[API ERROR] \({code} on \){path}:`, error);
  })
  .get("/health", () =&gt; ({
    status: "ok",
    timestamp: new Date().toISOString(),
  }))
  .get("/me", async ({ request, set }) =&gt; {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

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

    return { user: session.user };
  })
  .get("/payments/status", async ({ request, set }) =&gt; {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

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

    const purchase = await db
      .select()
      .from(purchases)
      .where(eq(purchases.userId, session.user.id))
      .limit(1);

    return {
      userId: session.user.id,
      purchase: purchase[0] ?? null,
    };
  });

export type Api = typeof api;
</code></pre>
<p>That last line is critical. <code>export type Api = typeof api</code> exports the full type signature of your API. Eden Treaty uses this type to generate a fully typed client on the frontend.</p>
<p>You'll see how that works shortly.</p>
<p>Notice the pattern for authenticated endpoints: call <code>auth.api.getSession()</code> with the request headers, check if the session exists, and return a 401 if it does not. This is straightforward and explicit. No decorators, no middleware magic.</p>
<p>The <code>onRequest</code> and <code>onError</code> hooks provide logging for every request. In production, you would replace these with structured logging to your observability platform.</p>
<h3 id="heading-how-to-mount-elysia-in-tanstack-start">How to Mount Elysia in TanStack Start</h3>
<p>TanStack Start uses file-based routing. To handle all API requests with Elysia, create a catch-all route at <code>src/routes/api.$.ts</code>:</p>
<pre><code class="language-typescript">// src/routes/api.$.ts
import { createFileRoute } from "@tanstack/react-router";

import { api } from "../server/api";

const handler = ({ request }: { request: Request }) =&gt; api.fetch(request);

export const Route = createFileRoute("/api/$")({
  server: {
    handlers: {
      GET: handler,
      POST: handler,
      PUT: handler,
      PATCH: handler,
      DELETE: handler,
      OPTIONS: handler,
    },
  },
});
</code></pre>
<p>The <code>$</code> in the filename is TanStack Router's wildcard syntax. This route matches any path starting with <code>/api/</code>, and the <code>server.handlers</code> object maps HTTP methods to your Elysia handler. Every request to <code>/api/*</code> gets forwarded to Elysia's <code>fetch</code> method.</p>
<p>This is the key architectural insight: Elysia is embedded inside TanStack Start. There is no separate API server. Your web app and API share the same process, the same port, and the same deployment.</p>
<p>This eliminates CORS issues, simplifies deployment, and means your API types are directly importable on the frontend.</p>
<p>Test your API by visiting <code>http://localhost:3000/api/health</code>. You should see:</p>
<pre><code class="language-json">{ "status": "ok", "timestamp": "2026-03-28T12:00:00.000Z" }
</code></pre>
<h2 id="heading-how-to-add-type-safe-api-calls-with-eden-treaty">How to Add Type-Safe API Calls with Eden Treaty</h2>
<p><a href="https://elysiajs.com/eden/treaty/overview">Eden Treaty</a> is Elysia's companion client library. It's an end-to-end type-safe HTTP client that mirrors your Elysia API's route structure as a JavaScript object. Instead of writing <code>fetch("/api/users")</code> and manually typing the response, you call <code>api.api.users.get()</code> and get full autocompletion, parameter validation, and return type inference, all derived from your server code at compile time with zero code generation.</p>
<p>This is what makes the stack special. Eden Treaty reads the type exported from your Elysia API and generates a fully typed client. Every endpoint, every parameter, every response shape is inferred at compile time.</p>
<h3 id="heading-how-to-set-up-the-treaty-client">How to Set Up the Treaty Client</h3>
<p>Since Elysia is embedded in your TanStack Start app (same origin), you don't need to pass a URL to the Treaty client. You can create the client directly from the Elysia app instance for server-side usage and use a URL-based client for browser-side usage.</p>
<p>The simplest approach is to create a helper function that returns a treaty client:</p>
<pre><code class="language-typescript">// src/lib/treaty.ts
import { treaty } from "@elysiajs/eden";

import type { Api } from "@/server/api";

// For client-side usage, connect to the same origin
export const api = treaty&lt;Api&gt;(
  typeof window !== "undefined"
    ? window.location.origin
    : (process.env.BETTER_AUTH_URL ?? "http://localhost:3000")
);
</code></pre>
<p>Now you can use <code>api</code> anywhere in your application with full type safety:</p>
<pre><code class="language-typescript">// Calling GET /api/health
const { data } = await api.api.health.get();
// data is typed as { status: string, timestamp: string }

// Calling GET /api/me (authenticated)
const { data: me, error } = await api.api.me.get();
// data is typed as { user: { id: string, email: string, ... } }
// error is typed as { error: string } | null
</code></pre>
<p>Notice how the method chain mirrors your route structure. The <code>/api/health</code> endpoint becomes <code>api.api.health.get()</code>. Path segments become properties, and the HTTP method becomes the final function call.</p>
<p>This is all inferred from the <code>type Api = typeof api</code> export.</p>
<h3 id="heading-how-types-flow-from-server-to-client">How Types Flow from Server to Client</h3>
<p>Here's the full picture of how types flow through the stack:</p>
<pre><code class="language-text">┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  Drizzle Schema  │     │    Elysia API    │     │   Eden Treaty   │
│  (schema.ts)     │────▶│   (api.ts)       │────▶│   (client)      │
│                  │     │                  │     │                  │
│  type User =     │     │  .get("/me",     │     │  api.api.me     │
│  typeof users    │     │    () =&gt; user)   │     │    .get()       │
│  .$inferSelect   │     │                  │     │    → { user }   │
└─────────────────┘     └─────────────────┘     └─────────────────┘
</code></pre>
<p>First, <strong>Drizzle</strong> infers TypeScript types from your table definitions. The <code>User</code> type comes from the <code>users</code> table schema.</p>
<p>Then <strong>Elysia</strong> uses those types in route handlers. When a handler returns <code>{ user: session.user }</code>, Elysia captures the return type.</p>
<p>Finally, <strong>Eden Treaty</strong> reads the <code>type Api = typeof api</code> export and generates a client where every endpoint is fully typed.</p>
<p>If you add a field to your <code>users</code> table schema, Drizzle's inferred types update. If your Elysia handler returns that new field, Eden Treaty's client types update. If your React component accesses a field that no longer exists, TypeScript catches the error at compile time.</p>
<p>Zero code generation. Zero runtime overhead. Just TypeScript inference doing what it does best.</p>
<h3 id="heading-how-to-handle-errors-with-eden-treaty">How to Handle Errors with Eden Treaty</h3>
<p>Every Eden Treaty call returns a <code>{ data, error }</code> tuple. This isn't a thrown exception. It's a discriminated union that forces you to handle both success and failure cases:</p>
<pre><code class="language-typescript">const { data, error } = await api.api.me.get();

if (error) {
  // error is typed based on what your Elysia handler can return
  console.error("Failed to fetch user:", error);
  return null;
}

// data is now narrowed to the success type
console.log(data.user.email);
</code></pre>
<p>This pattern eliminates the "forgot to handle the error" class of bugs that are common with <code>fetch</code> or Axios, where errors are thrown and easily missed. With Eden Treaty, the TypeScript compiler reminds you.</p>
<h3 id="heading-how-to-use-eden-treaty-in-route-loaders">How to Use Eden Treaty in Route Loaders</h3>
<p>TanStack Start routes have <code>loader</code> functions that run on the server during SSR and on the client during navigation. You can use Eden Treaty in these loaders to fetch data before the page renders:</p>
<pre><code class="language-typescript">// src/routes/_authenticated/dashboard.tsx
import { createFileRoute } from "@tanstack/react-router";

import { api } from "@/lib/treaty";

export const Route = createFileRoute("/_authenticated/dashboard")({
  loader: async () =&gt; {
    const { data } = await api.api.payments.status.get();
    return { purchase: data?.purchase ?? null };
  },
  component: DashboardPage,
});

function DashboardPage() {
  const { purchase } = Route.useLoaderData();

  return (
    &lt;div&gt;
      &lt;h1&gt;Dashboard&lt;/h1&gt;
      {purchase ? (
        &lt;p&gt;Your plan: {purchase.tier}&lt;/p&gt;
      ) : (
        &lt;p&gt;No active plan.&lt;/p&gt;
      )}
    &lt;/div&gt;
  );
}
</code></pre>
<p>The <code>loader</code> runs before the component renders, so the page never shows a loading spinner for its initial data. <code>Route.useLoaderData()</code> returns fully typed data based on what the loader returns. Change the loader's return type, and TypeScript catches mismatches in the component.</p>
<h2 id="heading-how-to-add-authentication-with-better-auth">How to Add Authentication with Better Auth</h2>
<p>Every SaaS needs authentication. In this tutorial, you'll use Better Auth with GitHub OAuth. Better Auth is a framework-agnostic auth library that works natively with Drizzle and has first-class support for TanStack Start.</p>
<h3 id="heading-how-to-create-a-github-oauth-app">How to Create a GitHub OAuth App</h3>
<p>Before writing any code, create a GitHub OAuth application:</p>
<ol>
<li><p>Go to <a href="https://github.com/settings/developers">GitHub Developer Settings</a></p>
</li>
<li><p>Click "New OAuth App"</p>
</li>
<li><p>Set the Homepage URL to <code>http://localhost:3000</code></p>
</li>
<li><p>Set the Authorization callback URL to <code>http://localhost:3000/api/auth/callback/github</code></p>
</li>
<li><p>Click "Register application"</p>
</li>
<li><p>Copy the Client ID and generate a Client Secret</p>
</li>
</ol>
<p>Add these to a <code>.env</code> file at the project root:</p>
<pre><code class="language-bash"># .env
DATABASE_URL=postgres://postgres:postgres@db.localtest.me:5432/my_saas
BETTER_AUTH_SECRET=your-random-32-character-string-here
BETTER_AUTH_URL=http://localhost:3000
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
</code></pre>
<p>Generate a random secret for <code>BETTER_AUTH_SECRET</code>:</p>
<pre><code class="language-bash">openssl rand -base64 32
</code></pre>
<h3 id="heading-how-to-configure-the-auth-server">How to Configure the Auth Server</h3>
<p>Create <code>src/lib/auth/index.ts</code>. This is the server-side auth configuration:</p>
<pre><code class="language-typescript">// src/lib/auth/index.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { tanstackStartCookies } from "better-auth/tanstack-start";

import * as schema from "@/lib/db";
import { db } from "@/lib/db";

const isDev = process.env.NODE_ENV !== "production";
const baseURL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";

export const auth = betterAuth({
  baseURL,
  database: drizzleAdapter(db, {
    provider: "pg",
    usePlural: true,
    schema: {
      users: schema.users,
      sessions: schema.sessions,
      accounts: schema.accounts,
      verifications: schema.verifications,
    },
  }),

  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID ?? "",
      clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "",
    },
  },

  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7 days
    updateAge: 60 * 60 * 24,      // refresh daily
    cookieCache: {
      enabled: true,
      maxAge: 5 * 60, // 5 minutes
    },
  },

  trustedOrigins: isDev
    ? ["http://localhost:3000"]
    : [baseURL],

  plugins: [tanstackStartCookies()],
});

export type Auth = typeof auth;
export type Session = typeof auth.$Infer.Session;
</code></pre>
<p>Key details in this configuration:</p>
<ul>
<li><p><code>drizzleAdapter</code> connects Better Auth to your Drizzle database. The <code>usePlural: true</code> option tells it your tables are named <code>users</code> (not <code>user</code>), <code>sessions</code> (not <code>session</code>), and so on.</p>
</li>
<li><p><code>tanstackStartCookies()</code> is a plugin that handles cookie management for TanStack Start's SSR. Without this, sessions won't persist correctly during server-side rendering.</p>
</li>
<li><p><code>cookieCache</code> stores session data in the cookie for 5 minutes, reducing database lookups on every request.</p>
</li>
</ul>
<h3 id="heading-how-to-configure-the-auth-client">How to Configure the Auth Client</h3>
<p>Create <code>src/lib/auth/client.ts</code> for the browser-side auth client:</p>
<pre><code class="language-typescript">// src/lib/auth/client.ts
import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
  baseURL: "",
});

export const { signIn, signOut, useSession } = authClient;
</code></pre>
<p>The <code>baseURL</code> is an empty string because Elysia is embedded in your TanStack Start app. Auth requests go to <code>/api/auth/*</code> on the same origin. No separate auth server needed.</p>
<h3 id="heading-how-to-mount-auth-routes">How to Mount Auth Routes</h3>
<p>Better Auth needs to handle requests at <code>/api/auth/*</code>. Since Elysia handles all <code>/api/*</code> routes, you mount Better Auth's handler inside Elysia.</p>
<p>Add this to your <code>src/server/api.ts</code>:</p>
<pre><code class="language-typescript">// In src/server/api.ts, add Better Auth's handler
export const api = new Elysia({ prefix: "/api" })
  // Mount Better Auth to handle /api/auth/* routes
  .mount(auth.handler)
  // ... rest of your routes
</code></pre>
<p>The <code>.mount(auth.handler)</code> call tells Elysia to forward any request matching Better Auth's routes to the auth handler. This covers login, logout, session management, and OAuth callbacks.</p>
<h3 id="heading-how-to-protect-routes">How to Protect Routes</h3>
<p>TanStack Start uses layout routes to protect groups of pages. Create <code>src/routes/_authenticated.tsx</code>:</p>
<pre><code class="language-typescript">// src/routes/_authenticated.tsx
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeaders } from "@tanstack/react-start/server";

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

const getCurrentUser = createServerFn().handler(async () =&gt; {
  const rawHeaders = getRequestHeaders();
  const headers = new Headers(rawHeaders as HeadersInit);
  const session = await auth.api.getSession({ headers });
  return session?.user ?? null;
});

export const Route = createFileRoute("/_authenticated")({
  beforeLoad: async ({ location }) =&gt; {
    const user = await getCurrentUser();

    if (!user) {
      throw redirect({
        to: "/login",
        search: { redirect: location.pathname },
      });
    }

    return { user };
  },
  component: AuthenticatedLayout,
});

function AuthenticatedLayout() {
  return &lt;Outlet /&gt;;
}
</code></pre>
<p>The <code>_authenticated</code> prefix (with underscore) makes this a layout route in TanStack Router. Any route nested inside <code>src/routes/_authenticated/</code> will run the <code>beforeLoad</code> check first. If the user isn't logged in, they get redirected to <code>/login</code> with a redirect parameter so they return to the original page after signing in.</p>
<p>The <code>createServerFn</code> runs on the server during SSR. It reads the request cookies, checks for a valid session, and returns the user. This means your auth check happens server-side before any HTML is sent to the browser.</p>
<p>Now any file you create under <code>src/routes/_authenticated/</code> is automatically protected. For example, <code>src/routes/_authenticated/dashboard.tsx</code> requires authentication.</p>
<h3 id="heading-how-to-build-the-login-page">How to Build the Login Page</h3>
<p>Create a login page at <code>src/routes/login.tsx</code>:</p>
<pre><code class="language-typescript">// src/routes/login.tsx
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { z } from "zod";

import { signIn } from "@/lib/auth/client";

const searchSchema = z.object({
  redirect: z.string().optional(),
});

export const Route = createFileRoute("/login")({
  validateSearch: searchSchema,
  component: LoginPage,
});

function LoginPage() {
  const { redirect: redirectTo } = Route.useSearch();
  const [isLoading, setIsLoading] = useState(false);

  const handleGitHubLogin = async () =&gt; {
    setIsLoading(true);
    const callbackURL = redirectTo
      ? `\({window.location.origin}\){redirectTo}`
      : `${window.location.origin}/dashboard`;

    await signIn.social({
      provider: "github",
      callbackURL,
    });
  };

  return (
    &lt;div className="flex min-h-screen items-center justify-center"&gt;
      &lt;div className="w-full max-w-md rounded-lg border p-8"&gt;
        &lt;h1 className="mb-6 text-2xl font-bold"&gt;Sign In&lt;/h1&gt;
        &lt;button
          onClick={handleGitHubLogin}
          disabled={isLoading}
          className="w-full rounded-md bg-gray-900 px-4 py-3 text-white"
        &gt;
          {isLoading ? "Signing in..." : "Sign in with GitHub"}
        &lt;/button&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>TanStack Router's <code>validateSearch</code> validates query parameters with Zod. The <code>redirect</code> parameter is typed as an optional string, and <code>Route.useSearch()</code> returns a type-safe object. No manual parsing needed.</p>
<h3 id="heading-how-to-add-login-redirect-middleware">How to Add Login Redirect Middleware</h3>
<p>You also want to redirect authenticated users away from the login page. Create the entry point at <code>src/start.ts</code>:</p>
<pre><code class="language-typescript">// src/start.ts
import { redirect } from "@tanstack/react-router";
import { createMiddleware, createStart } from "@tanstack/react-start";
import { getRequestHeaders, getRequestUrl } from "@tanstack/react-start/server";

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

const authMiddleware = createMiddleware({ type: "request" }).server(
  async ({ next }) =&gt; {
    const rawHeaders = getRequestHeaders();
    const headers = new Headers(rawHeaders as HeadersInit);
    const url = getRequestUrl();

    if (url.pathname !== "/login") {
      return next();
    }

    const session = await auth.api.getSession({ headers });

    if (session?.user) {
      const redirectTo = url.searchParams.get("redirect");
      throw redirect({
        to: redirectTo || "/dashboard",
      });
    }

    return next();
  }
);

export const startInstance = createStart(() =&gt; ({
  requestMiddleware: [authMiddleware],
}));
</code></pre>
<p>This middleware runs on every request. If the user is already authenticated and visits <code>/login</code>, they get redirected to the dashboard (or to whatever page they originally wanted to reach).</p>
<h2 id="heading-how-to-build-a-complete-feature-the-four-layer-pattern">How to Build a Complete Feature (The Four-Layer Pattern)</h2>
<p>Now that you have a database, API, type-safe client, and authentication, it's time to build a real feature. Every feature in this architecture follows the same four-layer pattern:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a694d8d4dc9b42434c218f/2e658c33-30fa-49ea-b5fc-50428d336cc4.png" alt="The four-layer feature pattern used throughout the tutorial: Layer 1 Schema defines the data structure, Layer 2 API exposes CRUD operations, Layer 3 Hooks connects React to the API, and Layer 4 UI renders and handles user interactions" style="display:block;margin:0 auto" width="5504" height="3072" loading="lazy">

<p>Once you understand this pattern, adding features becomes mechanical. Let's walk through building a complete purchase status feature that lets authenticated users check their purchase history.</p>
<h3 id="heading-layer-1-schema">Layer 1: Schema</h3>
<p>You already defined the <code>purchases</code> table in your schema earlier. For reference:</p>
<pre><code class="language-typescript">// src/lib/db/schema.ts
export const purchases = pgTable("purchases", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() =&gt; crypto.randomUUID()),
  userId: text("user_id")
    .notNull()
    .references(() =&gt; users.id, { onDelete: "cascade" }),
  stripeCheckoutSessionId: text("stripe_checkout_session_id")
    .notNull()
    .unique(),
  stripeCustomerId: text("stripe_customer_id"),
  stripePaymentIntentId: text("stripe_payment_intent_id"),
  tier: purchaseTierEnum("tier").notNull(),
  status: purchaseStatusEnum("status").notNull().default("completed"),
  amount: integer("amount").notNull(),
  currency: text("currency").notNull().default("usd"),
  purchasedAt: timestamp("purchased_at").notNull().defaultNow(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
</code></pre>
<p>If you're adding a new feature, this is where you start. Define the table, run <code>bun run db:push</code>, and move to Layer 2.</p>
<h3 id="heading-layer-2-api">Layer 2: API</h3>
<p>Create an API route module at <code>src/server/routes/purchases.ts</code>:</p>
<pre><code class="language-typescript">// src/server/routes/purchases.ts
import { eq } from "drizzle-orm";
import { Elysia } from "elysia";

import { auth } from "@/lib/auth";
import { db, purchases } from "@/lib/db";

export const purchasesRoute = new Elysia({ prefix: "/purchases" })
  .get("/status", async ({ request, set }) =&gt; {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

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

    const purchase = await db
      .select()
      .from(purchases)
      .where(eq(purchases.userId, session.user.id))
      .limit(1);

    return purchase[0] ?? null;
  });
</code></pre>
<p>Then register this route module in your main API file:</p>
<pre><code class="language-typescript">// src/server/api.ts
import { purchasesRoute } from "./routes/purchases";

export const api = new Elysia({ prefix: "/api" })
  .mount(auth.handler)
  .use(purchasesRoute)
  // ... other routes
</code></pre>
<p>The <code>.use()</code> method composes Elysia instances. Each route module is an independent Elysia instance with its own prefix, and <code>use</code> merges them into the main app. Eden Treaty sees the full composed type, so your client automatically knows about the new endpoints.</p>
<h3 id="heading-layer-3-hooks">Layer 3: Hooks</h3>
<p>Create a custom hook that connects your React components to the API:</p>
<pre><code class="language-typescript">// src/hooks/use-purchase-status.ts
import { useQuery } from "@tanstack/react-query";

import { api } from "@/lib/treaty";

export function usePurchaseStatus() {
  return useQuery({
    queryKey: ["purchase-status"],
    queryFn: async () =&gt; {
      const { data, error } = await api.api.purchases.status.get();
      if (error) throw new Error("Failed to fetch purchase status");
      return data;
    },
  });
}
</code></pre>
<p>TanStack Query handles caching, refetching, loading states, and error states. The <code>queryKey</code> identifies this data in the cache. If multiple components call <code>usePurchaseStatus()</code>, only one network request is made.</p>
<p>For mutations (creating, updating, or deleting data), use <code>useMutation</code>:</p>
<pre><code class="language-typescript">// src/hooks/use-checkout.ts
import { useMutation } from "@tanstack/react-query";

import { api } from "@/lib/treaty";

export function useCheckout() {
  return useMutation({
    mutationFn: async () =&gt; {
      const { data, error } = await api.api.payments.checkout.post();
      if (error) throw new Error("Failed to create checkout session");
      return data;
    },
    onSuccess: (data) =&gt; {
      // Redirect to Stripe Checkout
      if (data?.url) {
        window.location.href = data.url;
      }
    },
  });
}
</code></pre>
<h3 id="heading-layer-4-ui">Layer 4: UI</h3>
<p>Use the hooks in your React components:</p>
<pre><code class="language-tsx">// src/components/purchase-status.tsx
import { usePurchaseStatus } from "@/hooks/use-purchase-status";

export function PurchaseStatus() {
  const { data: purchase, isLoading, error } = usePurchaseStatus();

  if (isLoading) {
    return &lt;div&gt;Loading...&lt;/div&gt;;
  }

  if (error) {
    return &lt;div&gt;Failed to load purchase status.&lt;/div&gt;;
  }

  if (!purchase) {
    return (
      &lt;div className="rounded-lg border p-6"&gt;
        &lt;h2 className="text-lg font-semibold"&gt;No Active Purchase&lt;/h2&gt;
        &lt;p className="mt-2 text-gray-600"&gt;
          You have not purchased a plan yet.
        &lt;/p&gt;
      &lt;/div&gt;
    );
  }

  return (
    &lt;div className="rounded-lg border p-6"&gt;
      &lt;h2 className="text-lg font-semibold"&gt;
        {purchase.tier.charAt(0).toUpperCase() + purchase.tier.slice(1)} Plan
      &lt;/h2&gt;
      &lt;p className="mt-2 text-gray-600"&gt;
        Status: {purchase.status}
      &lt;/p&gt;
      &lt;p className="text-sm text-gray-500"&gt;
        Purchased on{" "}
        {new Date(purchase.purchasedAt).toLocaleDateString()}
      &lt;/p&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>That's the complete four-layer pattern. The schema defines the data. The API exposes it. Hooks connect React to the API. The UI renders the result. Every feature you add follows these same four steps.</p>
<h3 id="heading-how-the-layers-connect">How the Layers Connect</h3>
<p>Here's the full picture of how data flows through the four layers for a read operation:</p>
<pre><code class="language-text">User clicks "Dashboard"
  → TanStack Router triggers the route loader
    → Loader calls api.api.purchases.status.get() via Eden Treaty
      → Elysia receives GET /api/purchases/status
        → Handler calls auth.api.getSession() to verify the user
        → Handler queries db.select().from(purchases) via Drizzle
        → Handler returns { purchase } with inferred types
      → Eden Treaty receives typed response
    → Loader returns typed data
  → Component renders with Route.useLoaderData()
</code></pre>
<p>For a write operation (creating a new resource), the flow is similar but uses a mutation:</p>
<pre><code class="language-text">User clicks "Buy Now"
  → onClick calls checkout.mutate() from useMutation hook
    → mutationFn calls api.api.payments.checkout.post() via Eden Treaty
      → Elysia receives POST /api/payments/checkout
        → Handler creates a Stripe checkout session
        → Handler returns { url }
      → Eden Treaty receives typed response
    → onSuccess redirects to Stripe Checkout
</code></pre>
<h3 id="heading-how-to-add-a-second-feature">How to Add a Second Feature</h3>
<p>To cement the pattern, let's walk through adding a user profile update feature. This shows all four layers for a write operation.</p>
<p><strong>Layer 1: Schema.</strong> The <code>users</code> table already has a <code>name</code> field you can update. No schema change needed.</p>
<p><strong>Layer 2: API.</strong> Add a PATCH endpoint:</p>
<pre><code class="language-typescript">// In src/server/api.ts
.patch(
  "/me",
  async ({ request, body, set }) =&gt; {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

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

    const [updatedUser] = await db
      .update(users)
      .set({
        name: body.name,
        updatedAt: new Date(),
      })
      .where(eq(users.id, session.user.id))
      .returning();

    return { user: updatedUser };
  },
  {
    body: t.Object({
      name: t.String({ minLength: 1, maxLength: 100 }),
    }),
  },
)
</code></pre>
<p>The <code>body</code> option validates the request body at runtime and provides TypeScript types at compile time. If someone sends a request without a <code>name</code> field, Elysia returns a 400 error automatically. You don't write any validation logic yourself.</p>
<p><strong>Layer 3: Hooks.</strong> Create a mutation hook:</p>
<pre><code class="language-typescript">// src/hooks/use-update-profile.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";

import { api } from "@/lib/treaty";

export function useUpdateProfile() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (data: { name: string }) =&gt; {
      const { data: result, error } = await api.api.me.patch(data);
      if (error) throw new Error("Failed to update profile");
      return result;
    },
    onSuccess: () =&gt; {
      // Invalidate any queries that depend on user data
      queryClient.invalidateQueries({ queryKey: ["me"] });
    },
  });
}
</code></pre>
<p>The <code>onSuccess</code> callback invalidates the cache for user-related queries. This means any component displaying user data will automatically refetch and show the updated name.</p>
<p><strong>Layer 4: UI.</strong> Use the hook in a form component:</p>
<pre><code class="language-tsx">// src/components/profile-form.tsx
import { useState } from "react";

import { useUpdateProfile } from "@/hooks/use-update-profile";

export function ProfileForm({ currentName }: { currentName: string }) {
  const [name, setName] = useState(currentName);
  const updateProfile = useUpdateProfile();

  const handleSubmit = (e: React.FormEvent) =&gt; {
    e.preventDefault();
    updateProfile.mutate({ name });
  };

  return (
    &lt;form onSubmit={handleSubmit}&gt;
      &lt;label htmlFor="name" className="block text-sm font-medium"&gt;
        Display Name
      &lt;/label&gt;
      &lt;input
        id="name"
        type="text"
        value={name}
        onChange={(e) =&gt; setName(e.target.value)}
        className="mt-1 block w-full rounded-md border px-3 py-2"
      /&gt;
      &lt;button
        type="submit"
        disabled={updateProfile.isPending}
        className="mt-4 rounded-md bg-blue-600 px-4 py-2 text-white"
      &gt;
        {updateProfile.isPending ? "Saving..." : "Save"}
      &lt;/button&gt;
      {updateProfile.isError &amp;&amp; (
        &lt;p className="mt-2 text-sm text-red-600"&gt;
          Failed to update profile. Please try again.
        &lt;/p&gt;
      )}
    &lt;/form&gt;
  );
}
</code></pre>
<p>Four layers, second feature. The pattern is identical every time.</p>
<p>The pattern is deliberately repetitive. Repetition is a feature, not a bug. When every feature follows the same structure, you always know where to look.</p>
<p>New code goes in predictable places. And if you use an AI coding assistant, it can learn this pattern from your codebase and generate all four layers for new features.</p>
<h2 id="heading-how-to-add-payments-with-stripe">How to Add Payments with Stripe</h2>
<p>Most SaaS applications need to collect payments. You'll integrate Stripe for one-time purchases using Stripe Checkout. The key architectural decision is handling webhooks reliably using background jobs, which you'll add in the next section.</p>
<h3 id="heading-how-to-set-up-stripe">How to Set Up Stripe</h3>
<p>Create <code>src/lib/payments/index.ts</code>:</p>
<pre><code class="language-typescript">// src/lib/payments/index.ts
import Stripe from "stripe";

let stripeClient: Stripe | null = null;

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

// Lazy-initialized proxy so imports don't crash without env vars
export const stripe = new Proxy({} as Stripe, {
  get(_, prop) {
    return Reflect.get(getStripe(), prop);
  },
});

export async function createOneTimeCheckoutSession(params: {
  priceId: string;
  successUrl: string;
  cancelUrl: string;
  metadata: Record&lt;string, string&gt;;
  customerEmail?: string;
  couponId?: string;
}) {
  const client = getStripe();

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

  return session;
}

export async function retrieveCheckoutSession(sessionId: string) {
  const client = getStripe();
  return client.checkout.sessions.retrieve(sessionId);
}

export async function constructWebhookEvent(
  payload: string | Buffer,
  signature: string
) {
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
  if (!webhookSecret) {
    throw new Error("STRIPE_WEBHOOK_SECRET is not set");
  }
  const client = getStripe();
  return client.webhooks.constructEventAsync(payload, signature, webhookSecret);
}
</code></pre>
<p>The <code>Proxy</code> pattern for the Stripe client is a production technique. It lazily initializes the Stripe SDK so your module can be imported without crashing if the <code>STRIPE_SECRET_KEY</code> environment variable is missing. This is useful during builds and in environments where not every service is configured.</p>
<h3 id="heading-how-to-create-the-checkout-endpoint">How to Create the Checkout Endpoint</h3>
<p>Add a checkout endpoint to your API:</p>
<pre><code class="language-typescript">// In src/server/api.ts
.post("/payments/checkout", async ({ set }) =&gt; {
  const priceId = process.env.STRIPE_PRO_PRICE_ID;

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

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

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

  return { url: checkoutSession.url };
})
</code></pre>
<p>The <code>{CHECKOUT_SESSION_ID}</code> placeholder is a Stripe template variable. Stripe replaces it with the actual session ID when redirecting the user back to your app.</p>
<h3 id="heading-how-to-handle-webhooks">How to Handle Webhooks</h3>
<p>Stripe sends webhook events when payments are processed. Your webhook handler needs to verify the signature, parse the event, and process it.</p>
<p>Here's the critical design decision: don't do heavy processing inside the webhook handler. Stripe expects a response within a few seconds. If your handler takes too long, Stripe will retry the webhook, potentially causing duplicate processing.</p>
<p>Instead, use the "webhook receives, background job processes" pattern:</p>
<pre><code class="language-typescript">// In src/server/api.ts
.post("/payments/webhook", async ({ request, set }) =&gt; {
  const body = await request.text();
  const sig = request.headers.get("stripe-signature");

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

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

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

    return { received: true };
  } catch (error) {
    console.error("[Webhook] Stripe verification failed:", error);
    set.status = 400;
    return { error: "Webhook verification failed" };
  }
})
</code></pre>
<p>The webhook handler does three things: verifies the signature, identifies the event type, and forwards the data to Inngest for background processing. It responds immediately with <code>{ received: true }</code>. The actual business logic (sending emails, granting access, updating records) happens in the background job, which you'll build next.</p>
<h3 id="heading-how-to-claim-purchases-on-the-frontend">How to Claim Purchases on the Frontend</h3>
<p>After a successful checkout, Stripe redirects the user back to your app with a session ID. You need an endpoint that claims the purchase by verifying the session and creating a database record:</p>
<pre><code class="language-typescript">// In src/server/api.ts
.post(
  "/purchases/claim",
  async ({ body, request, set }) =&gt; {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

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

    const { sessionId } = body;

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

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

    // Verify payment with Stripe
    const stripeSession = await retrieveCheckoutSession(sessionId);

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

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

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

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

    return { success: true, tier };
  },
  {
    body: t.Object({
      sessionId: t.String(),
    }),
  }
)
</code></pre>
<p>Notice the idempotency check at the top. If the user refreshes the success page or the frontend retries the claim request, the endpoint returns the existing purchase instead of creating a duplicate.</p>
<p>This is essential for payment flows. You never want to accidentally charge someone twice or create duplicate records.</p>
<p>The <code>inngest.send()</code> call triggers background processing for the purchase. That's where you send confirmation emails, grant access to resources, track analytics events, and perform any other post-purchase work.</p>
<h3 id="heading-how-to-test-payments-locally">How to Test Payments Locally</h3>
<p>Install the Stripe CLI and forward webhooks to your local server:</p>
<pre><code class="language-bash"># Install Stripe CLI (macOS)
brew install stripe/stripe-cli/stripe

# Login to Stripe
stripe login

# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/api/payments/webhook
</code></pre>
<p>The Stripe CLI gives you a webhook signing secret that starts with <code>whsec_</code>. Add it to your <code>.env</code>:</p>
<pre><code class="language-bash">STRIPE_WEBHOOK_SECRET=whsec_your-local-webhook-secret
</code></pre>
<p>Create a test product and price in your Stripe dashboard (or use the Stripe CLI), then add the price ID to your <code>.env</code>:</p>
<pre><code class="language-bash">STRIPE_SECRET_KEY=sk_test_your-test-secret-key
STRIPE_PRO_PRICE_ID=price_your-test-price-id
</code></pre>
<h2 id="heading-how-to-add-background-jobs-with-inngest">How to Add Background Jobs with Inngest</h2>
<p>Background jobs are critical for any SaaS. You use them for processing webhooks, sending emails, granting access to resources, and any work that shouldn't block your API response. Inngest provides durable, retry-able functions with built-in checkpointing.</p>
<h3 id="heading-why-background-jobs-matter">Why Background Jobs Matter</h3>
<p>Consider what happens when someone purchases your SaaS product:</p>
<ol>
<li><p>Verify the payment with Stripe</p>
</li>
<li><p>Create a purchase record in the database</p>
</li>
<li><p>Send a confirmation email to the customer</p>
</li>
<li><p>Send a notification email to the admin</p>
</li>
<li><p>Grant access to a private GitHub repository</p>
</li>
<li><p>Track the purchase event in your analytics platform</p>
</li>
<li><p>Schedule a follow-up email sequence</p>
</li>
</ol>
<p>If you try to do all of this inside an API endpoint, several things can go wrong. The email service might be down. The GitHub API might rate-limit you. Your analytics call might time out.</p>
<p>Any failure means the user sees an error, and you have to figure out which steps completed and which did not.</p>
<p>Inngest solves this with durable execution. Each step is checkpointed. If step 3 fails, Inngest retries step 3 without re-running steps 1 and 2.</p>
<p>If the entire function fails, Inngest retries the whole thing. You get at-least-once execution with automatic deduplication.</p>
<h3 id="heading-how-to-set-up-inngest">How to Set Up Inngest</h3>
<p>Create the Inngest client at <code>src/lib/jobs/client.ts</code>:</p>
<pre><code class="language-typescript">// src/lib/jobs/client.ts
import { Inngest } from "inngest";

export const inngest = new Inngest({
  id: "my-saas",
});
</code></pre>
<h3 id="heading-how-to-write-your-first-inngest-function">How to Write Your First Inngest Function</h3>
<p>Create <code>src/lib/jobs/functions/stripe.ts</code> with the purchase completion handler:</p>
<pre><code class="language-typescript">// src/lib/jobs/functions/stripe.ts
import { eq } from "drizzle-orm";

import { inngest } from "../client";
import { db, purchases, users } from "@/lib/db";

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

    // Step 1: Look up user and purchase details
    const { user, purchase } = await step.run(
      "lookup-user-and-purchase",
      async () =&gt; {
        const userResult = await db
          .select({
            id: users.id,
            email: users.email,
            name: users.name,
          })
          .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,
          })
          .from(purchases)
          .where(eq(purchases.stripeCheckoutSessionId, sessionId))
          .limit(1);

        return {
          user: foundUser,
          purchase: purchaseResult[0] ?? {
            amount: 0,
            currency: "usd",
          },
        };
      }
    );

    // Step 2: Send purchase confirmation email
    await step.run("send-purchase-confirmation", async () =&gt; {
      // Send email using your email service (Resend, SendGrid, and so on)
      console.log(
        `Sending purchase confirmation to ${user.email}`
      );
      // await sendEmail({
      //   to: user.email,
      //   subject: "Your purchase is confirmed!",
      //   template: PurchaseConfirmationEmail,
      // });
    });

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

      console.log(
        `Notifying admin about purchase from ${user.email}`
      );
      // await sendEmail({
      //   to: adminEmail,
      //   subject: `New sale: ${user.email}`,
      //   template: AdminNotificationEmail,
      // });
    });

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

    return { success: true, userId, tier };
  }
);

export const stripeFunctions = [handlePurchaseCompleted];
</code></pre>
<p>Each <code>step.run()</code> is a checkpoint. If the function fails after step 2, Inngest retries from step 3, not from the beginning. The results of completed steps are cached.</p>
<h3 id="heading-how-to-register-your-functions">How to Register Your Functions</h3>
<p>Create an index file that collects all your functions:</p>
<pre><code class="language-typescript">// src/lib/jobs/functions/index.ts
import { stripeFunctions } from "./stripe";

export const functions = [...stripeFunctions];
</code></pre>
<p>And a barrel export:</p>
<pre><code class="language-typescript">// src/lib/jobs/index.ts
export { inngest } from "./client";
export { functions } from "./functions";
</code></pre>
<h3 id="heading-how-to-connect-inngest-to-your-api">How to Connect Inngest to Your API</h3>
<p>Mount the Inngest handler in your Elysia API. Add this to <code>src/server/api.ts</code>:</p>
<pre><code class="language-typescript">// src/server/api.ts
import { serve } from "inngest/bun";

import { inngest, functions } from "@/lib/jobs";

const inngestHandler = serve({
  client: inngest,
  functions,
});

export const api = new Elysia({ prefix: "/api" })
  // Inngest endpoint - handles function registration and execution
  .all("/inngest", async (ctx) =&gt; {
    return inngestHandler(ctx.request);
  })
  // ... rest of your routes
</code></pre>
<p>The <code>.all("/inngest")</code> route handles both GET (for function registration) and POST (for function execution) requests from Inngest.</p>
<h3 id="heading-how-to-run-inngest-locally">How to Run Inngest Locally</h3>
<p>Inngest provides a dev server that runs locally and provides a dashboard for monitoring your functions:</p>
<pre><code class="language-bash">npx inngest-cli@latest dev -u http://localhost:3000/api/inngest --no-discovery
</code></pre>
<p>This starts the Inngest dev server at <code>http://localhost:8288</code>. Open that URL in your browser to see a dashboard showing your registered functions, event history, and function execution logs.</p>
<p>The <code>-u</code> flag tells Inngest where your app is running. The <code>--no-discovery</code> flag disables automatic app discovery, which is more reliable for local development.</p>
<p>Add this as a script in your <code>package.json</code>:</p>
<pre><code class="language-json">{
  "scripts": {
    "inngest:dev": "npx inngest-cli@latest dev -u http://localhost:3000/api/inngest --no-discovery"
  }
}
</code></pre>
<p>Now you can trigger your functions by sending events from your API:</p>
<pre><code class="language-typescript">await inngest.send({
  name: "purchase/completed",
  data: {
    userId: "user_123",
    tier: "pro",
    sessionId: "cs_test_abc",
  },
});
</code></pre>
<p>The event appears in the Inngest dashboard, the function executes step by step, and you can see the output of each step. If a step fails, you can retry it manually from the dashboard.</p>
<h3 id="heading-how-to-handle-refunds-with-background-jobs">How to Handle Refunds with Background Jobs</h3>
<p>Here's a more complex example that shows why durable execution matters. When processing a refund, you need to update the purchase status, revoke access, send notifications, and track analytics. If any step fails, the others should still complete:</p>
<pre><code class="language-typescript">// src/lib/jobs/functions/stripe.ts
export const handleRefund = inngest.createFunction(
  {
    id: "refund-processed",
    triggers: [{ event: "stripe/charge.refunded" }],
  },
  async ({ event, step }) =&gt; {
    const { paymentIntentId, amountRefunded, originalAmount, currency } =
      event.data as {
        chargeId: string;
        paymentIntentId: string;
        amountRefunded: number;
        originalAmount: number;
        currency: string;
      };

    const isFullRefund = amountRefunded &gt;= originalAmount;

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

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

        const userResult = await db
          .select()
          .from(users)
          .where(eq(users.id, purchaseResult[0].userId))
          .limit(1);

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

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

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

    // Step 3: Send customer notification
    await step.run("notify-customer", async () =&gt; {
      console.log(
        `Sending \({isFullRefund ? "full" : "partial"} refund notification to \){user.email}`
      );
      // await sendEmail({ ... });
    });

    return { success: true, isFullRefund };
  }
);
</code></pre>
<p>Even if the email service is down in step 3, step 2 (updating the database) has already completed and will not be re-run. Inngest retries only the failed step.</p>
<p>This is what makes durable execution valuable for payment processing. You get reliable, idempotent processing without building your own retry logic.</p>
<h2 id="heading-how-to-deploy-to-vercel-with-neon">How to Deploy to Vercel with Neon</h2>
<p>You now have a working application with authentication, a database, a type-safe API, payments, and background jobs. Time to deploy it.</p>
<h3 id="heading-how-to-provision-a-neon-database">How to Provision a Neon Database</h3>
<ol>
<li><p>Sign up at <a href="https://neon.tech">neon.tech</a> and create a new project</p>
</li>
<li><p>Choose a region close to your users (Neon supports multiple AWS regions)</p>
</li>
<li><p>Copy the connection string from the dashboard</p>
</li>
</ol>
<p>The connection string looks like this:</p>
<pre><code class="language-text">postgresql://username:password@ep-something.us-east-1.aws.neon.tech/my_saas?sslmode=require
</code></pre>
<h3 id="heading-how-to-run-migrations-in-production">How to Run Migrations in Production</h3>
<p>For production, you should use versioned migrations instead of <code>db:push</code>. Generate a migration from your schema:</p>
<pre><code class="language-bash">bun run db:generate
</code></pre>
<p>This creates SQL files in the <code>drizzle/</code> directory. Review the generated SQL to make sure it matches your expectations. Then apply the migration:</p>
<pre><code class="language-bash">DATABASE_URL="your-neon-connection-string" bun run db:migrate
</code></pre>
<h3 id="heading-how-to-deploy-to-vercel">How to Deploy to Vercel</h3>
<ol>
<li><p>Push your code to a GitHub repository</p>
</li>
<li><p>Go to <a href="https://vercel.com/new">vercel.com/new</a> and import your repository</p>
</li>
<li><p>Vercel will auto-detect TanStack Start and configure the build settings</p>
</li>
</ol>
<p>Set the following environment variables in Vercel's dashboard:</p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><code>DATABASE_URL</code></td>
<td>Your Neon connection string</td>
</tr>
<tr>
<td><code>BETTER_AUTH_SECRET</code></td>
<td>Your random 32+ character string</td>
</tr>
<tr>
<td><code>BETTER_AUTH_URL</code></td>
<td><code>https://your-app.vercel.app</code></td>
</tr>
<tr>
<td><code>GITHUB_CLIENT_ID</code></td>
<td>Your GitHub OAuth client ID</td>
</tr>
<tr>
<td><code>GITHUB_CLIENT_SECRET</code></td>
<td>Your GitHub OAuth client secret</td>
</tr>
<tr>
<td><code>STRIPE_SECRET_KEY</code></td>
<td>Your Stripe secret key (live)</td>
</tr>
<tr>
<td><code>STRIPE_WEBHOOK_SECRET</code></td>
<td>Your Stripe webhook secret (production)</td>
</tr>
<tr>
<td><code>STRIPE_PRO_PRICE_ID</code></td>
<td>Your Stripe price ID</td>
</tr>
</tbody></table>
<p>Click "Deploy." Vercel builds your app and deploys it to a <code>.vercel.app</code> URL.</p>
<h3 id="heading-how-to-update-oauth-callbacks">How to Update OAuth Callbacks</h3>
<p>After deploying, update your GitHub OAuth app's callback URL:</p>
<ol>
<li><p>Go to your GitHub OAuth app settings</p>
</li>
<li><p>Change the <strong>Authorization callback URL</strong> to <code>https://your-app.vercel.app/api/auth/callback/github</code></p>
</li>
<li><p>Add <code>https://your-app.vercel.app</code> as the <strong>Homepage URL</strong></p>
</li>
</ol>
<h3 id="heading-how-to-configure-stripe-webhooks-for-production">How to Configure Stripe Webhooks for Production</h3>
<p>Create a webhook endpoint in the Stripe dashboard:</p>
<ol>
<li><p>Go to <a href="https://dashboard.stripe.com/webhooks">Stripe Dashboard &gt; Developers &gt; Webhooks</a></p>
</li>
<li><p>Click "Add endpoint"</p>
</li>
<li><p>Set the URL to <code>https://your-app.vercel.app/api/payments/webhook</code></p>
</li>
<li><p>Select the events you want to receive (<code>charge.refunded</code>, <code>checkout.session.expired</code>, and so on)</p>
</li>
<li><p>Copy the webhook signing secret and add it to Vercel's environment variables</p>
</li>
</ol>
<h3 id="heading-how-to-set-up-inngest-in-production">How to Set Up Inngest in Production</h3>
<p>Inngest has a cloud service that handles function execution in production:</p>
<ol>
<li><p>Sign up at <a href="https://www.inngest.com">inngest.com</a></p>
</li>
<li><p>Create an app and copy your event key and signing key</p>
</li>
<li><p>Add <code>INNGEST_EVENT_KEY</code> and <code>INNGEST_SIGNING_KEY</code> to Vercel's environment variables</p>
</li>
<li><p>In Inngest's dashboard, set your app URL to <code>https://your-app.vercel.app/api/inngest</code></p>
</li>
</ol>
<p>Inngest automatically discovers your functions and starts processing events.</p>
<h3 id="heading-common-deployment-pitfalls">Common Deployment Pitfalls</h3>
<p><strong>1. SSR externals.</strong> Some packages do not work with Vite's SSR bundling. If you see errors about packages like <code>elysia</code> or <code>inngest</code> during the build, add them to the <code>ssr.external</code> array in <code>vite.config.ts</code>:</p>
<pre><code class="language-typescript">// vite.config.ts
export default defineConfig({
  ssr: {
    external: ["elysia", "inngest"],
  },
  // ...
});
</code></pre>
<p><strong>2. Environment variable access.</strong> In TanStack Start, server-side code can access <code>process.env</code> directly. Client-side code can only access variables prefixed with <code>VITE_</code>. Your Stripe secret key and database URL should never have the <code>VITE_</code> prefix.</p>
<p><strong>3. Neon connection pooling.</strong> For production, use the pooled connection string from Neon (it uses port 5432 instead of the direct connection on port 5433). The pooled connection handles concurrent requests better.</p>
<p><strong>4. Build failures.</strong> If your build fails, the most common cause is a TypeScript error. Run <code>bun run type-check</code> locally before pushing. Fix all errors before deploying.</p>
<p><strong>5. Missing environment variables.</strong> If your app crashes immediately after deployment, check the Vercel function logs. The most common issue is a missing environment variable. Neon connection strings, Stripe keys, and Better Auth secrets all need to be set before the first deployment.</p>
<h3 id="heading-how-to-set-up-a-custom-domain">How to Set Up a Custom Domain</h3>
<p>Once your app is deployed to Vercel:</p>
<ol>
<li><p>Go to your project's Settings in Vercel</p>
</li>
<li><p>Click "Domains"</p>
</li>
<li><p>Add your custom domain</p>
</li>
<li><p>Update your DNS records as instructed (usually a CNAME record pointing to <code>cname.vercel-dns.com</code>)</p>
</li>
</ol>
<p>After adding a custom domain, update these environment variables in Vercel:</p>
<ul>
<li><p>Set <code>BETTER_AUTH_URL</code> to <code>https://yourdomain.com</code></p>
</li>
<li><p>Update your GitHub OAuth app's callback URL to <code>https://yourdomain.com/api/auth/callback/github</code></p>
</li>
<li><p>Update your Stripe webhook endpoint to <code>https://yourdomain.com/api/payments/webhook</code></p>
</li>
</ul>
<p>Vercel automatically provisions an SSL certificate for your custom domain. No additional configuration needed.</p>
<h3 id="heading-how-to-verify-your-deployment">How to Verify Your Deployment</h3>
<p>After deploying, run through this checklist:</p>
<ol>
<li><p><strong>Health check.</strong> Visit <code>https://yourdomain.com/api/health</code>. You should see a JSON response with <code>{ "status": "ok" }</code>.</p>
</li>
<li><p><strong>Authentication.</strong> Click "Sign in with GitHub" and complete the OAuth flow. You should be redirected to your dashboard.</p>
</li>
<li><p><strong>Database.</strong> After signing in, check your Neon dashboard. You should see a new row in the <code>users</code> table.</p>
</li>
<li><p><strong>Payments.</strong> On your pricing page, click "Buy" and use Stripe's test card (<code>4242 4242 4242 4242</code>) to complete a purchase. Check that a purchase record appears in your database.</p>
</li>
<li><p><strong>Background jobs.</strong> After a test purchase, check the Inngest dashboard. You should see a <code>purchase/completed</code> event and the corresponding function execution.</p>
</li>
</ol>
<p>If any of these steps fail, check the Vercel function logs (Settings, Functions, Logs) for error messages. Most deployment issues are misconfigured environment variables or missing webhook secrets.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You just built a production-ready SaaS application. Let's recap what you have:</p>
<ul>
<li><p><strong>TanStack Start</strong> handles server-side rendering, file-based routing, and the dev server</p>
</li>
<li><p><strong>Elysia</strong> provides a type-safe API embedded in the same process as your web app</p>
</li>
<li><p><strong>Eden Treaty</strong> gives you a fully typed API client with zero code generation</p>
</li>
<li><p><strong>Drizzle ORM with Neon</strong> handles your database with type-safe queries and serverless PostgreSQL</p>
</li>
<li><p><strong>Better Auth</strong> provides GitHub OAuth with session management and route protection</p>
</li>
<li><p><strong>Stripe</strong> processes payments with webhook handling</p>
</li>
<li><p><strong>Inngest</strong> runs reliable background jobs with automatic retries and checkpointing</p>
</li>
<li><p><strong>Vercel</strong> hosts everything with zero infrastructure management</p>
</li>
</ul>
<p>The four-layer pattern (Schema, API, Hooks, UI) gives you a repeatable process for adding new features. Every feature follows the same structure. Define the data, expose it through the API, connect it to React with hooks, and render it in your components.</p>
<p>This architecture scales well. The explicit boundaries between layers mean you can swap out individual pieces without rewriting everything.</p>
<p>If you outgrow Neon, switch to a self-hosted PostgreSQL. If you need a different payment provider, replace the Stripe module. The rest of the application doesn't change.</p>
<p>What you build next is up to you. Here are natural next steps:</p>
<ul>
<li><p><strong>Email notifications</strong> with <a href="https://resend.com">Resend</a> and <a href="https://react.email">React Email</a> for transactional emails (purchase confirmations, password resets, welcome sequences)</p>
</li>
<li><p><strong>Analytics</strong> with <a href="https://posthog.com">PostHog</a> for tracking user behavior and feature flags</p>
</li>
<li><p><strong>Error tracking</strong> with <a href="https://sentry.io">Sentry</a> for catching production errors before your users report them</p>
</li>
<li><p><strong>Content management</strong> with MDX for a blog or documentation section</p>
</li>
<li><p><strong>File uploads</strong> with S3-compatible storage for user-generated content</p>
</li>
</ul>
<p>The <code>src/lib/</code> pattern makes adding new integrations straightforward. Create a new directory, add an <code>index.ts</code>, and import it where you need it. Each integration stays isolated, so adding analytics does not affect your payment code.</p>
<p>If you want to skip the setup and start building your product immediately, <a href="https://eden-stack.com?utm_source=freecodecamp&amp;utm_medium=article&amp;utm_campaign=fullstack-saas-handbook">Eden Stack</a> includes everything from this article (and more), pre-configured and production-tested. It ships with 30+ Claude Code skills that encode the patterns described here, so AI coding assistants can generate features following your codebase conventions out of the box.</p>
<p>Whatever you build, build it with type safety. The feedback loop of "change the schema, see the errors, fix the errors" is the fastest way I know to ship reliable software.</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=fullstack-saas-handbook"><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>
        
    </channel>
</rss>
