Stripe

Automate SaaS operations with n8n and Stripe webhooks

Keep billing and access state in your Next.js app, and hand the follow-up work (CRM, Slack, welcome emails) to n8n. Signature checks, idempotency, retries, and self-hosted vs n8n Cloud.

10 min read

This article contains affiliate links. If you buy through them, Designyff may earn a commission at no extra cost to you.

A new customer pays. Your app has to unlock the paid plan. Then someone usually wants more: a contact in the CRM, a message in a Slack channel, a welcome email, maybe a task to schedule an onboarding call. That second list changes every month, and it should not live in the code that decides who has access.

This guide splits the work. The Next.js app receives Stripe webhooks, verifies them, and owns billing and access state. n8n receives a small, already-verified event from the app and runs the operations around it. If an n8n workflow breaks, nobody loses access to what they paid for.

The Stripe side builds on Stripe webhooks with Next.js and synchronizing Stripe subscription state. Read those first if your app does not handle webhooks yet.

Who owns what

ConcernOwnerWhy
Signature check on Stripe eventsAppIt already has the raw body and the signing secret.
plan, subscription id, accessApp databaseOne source of truth for what a user can do.
Recording processed event idsApp databaseSame transaction as the state change.
CRM contact, Slack alert, onboarding taskn8nChanges often, owned by whoever runs ops.
Welcome sequence, newsletter tagsn8n → email toolMarketing work, often edited by non-developers.

Avoid the opposite design, where n8n receives Stripe events and calls back into the app to grant access. It works until a workflow is paused, a credential expires, or an instance restarts during a deploy. Then paid users sit on the free plan while Stripe sees a 200 from n8n and never retries.

Two ways to get events into n8n

Option A: the app forwards its own events (recommended). Stripe sends events only to your app. After the app commits a state change, it sends n8n a small event such as customer.activated with the user id, email, and plan. n8n never needs a Stripe secret key, and the payload is the one your ops team cares about, not Stripe’s full object.

Option B: a second Stripe endpoint for n8n. n8n’s Stripe Trigger node creates a webhook endpoint in your Stripe account when you publish the workflow. Stripe allows several endpoints, and each has its own signing secret. This is fine for ops-only reactions, like posting every charge.dispute.created to Slack. Two things to know:

  • Signature verification in the Stripe Trigger node needs n8n 2.25.7 or 2.26.2 and later, plus a Signature Secret on the Stripe credential. Without it, the node accepts unsigned requests. The credential docs explain how to copy the whsec_ value from the endpoint n8n created.
  • The n8n credential holds a Stripe secret key. Use a restricted key with only the permissions the workflow needs.

The rest of this guide uses option A.

Step 1: Record events and state in one transaction

Stripe can deliver the same event more than once and does not guarantee order (Stripe webhooks). The existing guide’s handler is safe to repeat because it writes the same plan twice. Sending a Slack message or creating a CRM deal twice is not safe. So the app records which events it has processed, and writes the outgoing ops event in the same transaction.

model StripeEvent {
  id          String   @id // Stripe event id, evt_...
  type        String
  processedAt DateTime @default(now())
}

model OpsEvent {
  id        String    @id @default(cuid())
  type      String    // "customer.activated", "customer.canceled"
  payload   Json
  createdAt DateTime  @default(now())
  sentAt    DateTime?
  attempts  Int       @default(0)
}

OpsEvent is an outbox. A row exists if and only if the state change committed. Sending it to n8n happens afterwards and can be retried.

// app/api/stripe/webhook/route.ts
import { NextResponse, after } from "next/server";
import Stripe from "stripe";
import { prisma } from "@/lib/prisma";
import { flushOpsEvents } from "@/lib/ops-events";

// Created on first use, so `next build` does not need the secret key.
let client: Stripe | undefined;
const stripe = () => (client ??= new Stripe(process.env.STRIPE_SECRET_KEY || ""));

export async function POST(request: Request) {
  let event: Stripe.Event;
  try {
    event = stripe().webhooks.constructEvent(
      await request.text(),
      request.headers.get("stripe-signature") || "",
      process.env.STRIPE_WEBHOOK_SECRET || ""
    );
  } catch {
    return NextResponse.json({ error: "invalid signature" }, { status: 400 });
  }

  try {
    await prisma.$transaction(async (tx) => {
      // Fails with P2002 if this event id was processed before.
      await tx.stripeEvent.create({ data: { id: event.id, type: event.type } });

      if (event.type === "checkout.session.completed") {
        const session = event.data.object;
        const userId = session.metadata?.userId;
        if (!userId) return;

        const user = await tx.user.update({
          where: { id: userId },
          data: {
            plan: "pro",
            stripeCustomerId: String(session.customer || ""),
            stripeSubscriptionId: String(session.subscription || ""),
          },
        });

        await tx.opsEvent.create({
          data: {
            type: "customer.activated",
            payload: { userId: user.id, email: user.email, plan: "pro", stripeEventId: event.id },
          },
        });
      }

      if (event.type === "customer.subscription.deleted") {
        const subscription = event.data.object;
        const user = await tx.user.findFirst({ where: { stripeSubscriptionId: subscription.id } });
        if (!user) return;

        await tx.user.update({ where: { id: user.id }, data: { plan: "free" } });
        await tx.opsEvent.create({
          data: {
            type: "customer.canceled",
            payload: { userId: user.id, email: user.email, stripeEventId: event.id },
          },
        });
      }
    });
  } catch (err) {
    if ((err as { code?: string }).code === "P2002") {
      return NextResponse.json({ received: true, duplicate: true });
    }
    throw err; // 500, so Stripe retries later
  }

  after(flushOpsEvents);
  return NextResponse.json({ received: true });
}

Notes on the handler:

  • Signature first, raw body, as in the webhook guide. A bad signature gets a 400 and nothing else runs.
  • If the state update throws, the StripeEvent insert rolls back too. Stripe retries, and the retry is processed normally. If you insert the event id outside the transaction, a failed update marks the event as done and the retry is skipped.
  • A duplicate delivery hits the primary key and returns 200 without side effects.
  • after runs the flush after the response is sent, so a slow n8n instance does not slow down your answer to Stripe. Stripe’s docs ask endpoints to return a 2xx quickly, before complex logic.
  • customer.subscription.updated is left out to keep the example short. Add it the same way when you want ops to hear about plan changes or past_due.

Step 2: Send the outbox to n8n

// lib/ops-events.ts
import { prisma } from "@/lib/prisma";

export async function flushOpsEvents() {
  const url = process.env.N8N_OPS_WEBHOOK_URL;
  const secret = process.env.N8N_OPS_WEBHOOK_SECRET;
  if (!url || !secret) return;

  const pending = await prisma.opsEvent.findMany({
    where: { sentAt: null, attempts: { lt: 10 } },
    orderBy: { createdAt: "asc" },
    take: 20,
  });

  for (const ev of pending) {
    try {
      const res = await fetch(url, {
        method: "POST",
        headers: { "Content-Type": "application/json", "X-Ops-Secret": secret },
        body: JSON.stringify({ id: ev.id, type: ev.type, data: ev.payload, createdAt: ev.createdAt }),
        signal: AbortSignal.timeout(5000),
      });
      if (!res.ok) throw new Error(`n8n responded ${res.status}`);
      await prisma.opsEvent.update({ where: { id: ev.id }, data: { sentAt: new Date() } });
    } catch (err) {
      console.error("ops event delivery failed", ev.id, err);
      await prisma.opsEvent.update({ where: { id: ev.id }, data: { attempts: { increment: 1 } } });
    }
  }
}

Unsent rows need a second chance, because n8n may be down when after runs. Call flushOpsEvents from a protected route every few minutes with a system cron job, or with your host’s cron feature. Rows that reach 10 attempts stop retrying. Look at them by hand.

Two flushes can overlap and send the same row twice. That is expected. The id field is the outbox row id, and n8n deduplicates on it in the next step. That is also why the payload carries the app’s id and not only Stripe’s.

Step 3: The n8n workflow

Build it from core nodes:

  1. Webhook node. Method POST. Under Authentication, choose Header Auth and create a credential with the name X-Ops-Secret and the same value as N8N_OPS_WEBHOOK_SECRET. Set Respond to Immediately so the app gets a 200 as soon as the event is accepted. Use the Production URL for N8N_OPS_WEBHOOK_URL. The test URL only listens while you click “Listen for test event” in the editor (Webhook node docs).
  2. Remove Duplicates node. Operation Remove Items Processed in Previous Executions, Keep Items Where set to Value Is New, and Value to Dedupe On set to {{ $json.body.id }}. n8n keeps the last 10,000 values by default (Remove Duplicates docs).
  3. Switch node on {{ $json.body.type }}, one output per event type.
  4. Actions per branch:
    • customer.activated: the HubSpot node’s Create/Update a contact (or your CRM’s node), a Slack Send a message to a sales or founders channel, and an onboarding task in your project tool.
    • customer.canceled: a Slack message, and a CRM update so nobody pitches an upgrade to someone who just left.

Turn on error notifications for the workflow. In n8n, an Error Trigger workflow can post failures to Slack. Each action node can retry on failure, which handles short outages at the CRM or Slack.

Welcome emails: transactional or marketing

Split these two:

  • Transactional. “Your account is ready, here is how to log in.” Every paying customer gets it. Send it from the app with the same email provider that sends password resets. Do not make it depend on n8n.
  • Marketing. An onboarding sequence with tips, case studies, and upgrade nudges. Only send it to people who agreed to marketing email. Under GDPR, that consent is a separate checkbox, not part of buying.

For the marketing part, n8n can add consenting customers to an email tool. With Kit, add an HTTP Request node that calls the v4 API: create the subscriber, then add a tag that starts a welcome sequence through a Kit automation. n8n’s built-in ConvertKit node uses the older API secret from the v3 era, so the HTTP Request node with an X-Kit-Api-Key header is the more direct route today. The calls and the v3 to v4 changes are covered in adding a Kit newsletter signup to Next.js. Put a marketingConsent flag in the customer.activated payload and branch on it in n8n.

n8n Cloud or self-hosted

Both run the same workflows.

n8n Cloud is the managed service. Updates, backups, and the public HTTPS URL for webhooks are handled for you. Pricing is per plan, with execution limits. Compare those limits with your expected event volume. One paying customer is a handful of executions, so most early SaaS volumes are small.

Self-hosted runs n8n’s Docker image (docker.n8n.io/n8nio/n8n) on your own server (Docker install docs). The parts that matter in production:

  • Persist /home/node/.n8n. That directory holds the encryption key for saved credentials. Set N8N_ENCRYPTION_KEY yourself and back it up, because without it a restored database cannot decrypt your credentials (encryption key docs).
  • Behind a reverse proxy, set the public webhook URL. n8n builds webhook URLs from its own host and port, which are wrong behind a proxy on 443. Set N8N_WEBHOOK_URL (it replaces WEBHOOK_URL, deprecated from n8n 2.35.0) and N8N_PROXY_HOPS=1 (reverse proxy docs).
  • Use Postgres for anything you depend on. The default is SQLite. n8n supports Postgres through DB_TYPE=postgresdb and the DB_POSTGRESDB_* variables.
  • License. n8n is distributed under its Sustainable Use License. Running it for your own company’s internal automation is the intended use. Read it before you build n8n into a product you sell to others.

If you already run your app on a VPS with Docker, n8n can be one more service in the same Compose file behind the same proxy. Self-hosting Next.js on a VPS with Docker covers that setup. Keep n8n on its own subdomain, and keep its database separate from the app’s.

Testing the whole path

  1. Run the app locally and forward Stripe events with stripe listen --forward-to localhost:3000/api/stripe/webhook, as in the webhook guide.
  2. Point N8N_OPS_WEBHOOK_URL at the workflow’s test URL while you build it, then switch to the production URL.
  3. Complete a test-mode Checkout. Check that the user row says pro, a StripeEvent row exists, and the OpsEvent row has sentAt set.
  4. Resend the same event from the Stripe Dashboard. The handler should answer duplicate: true, and no second Slack message should appear.
  5. Stop n8n, complete another Checkout, then start n8n and call the flush route. The queued event should arrive once.

Checklist

  • Stripe events go to the app. The app verifies the signature against the raw body.
  • Processed event ids and the state change are written in one transaction.
  • Ops events go into an outbox table and are sent after the response, with a cron retry.
  • The n8n Webhook node uses Header Auth and the production URL.
  • n8n deduplicates on the outbox id.
  • Transactional email stays in the app. Marketing email needs consent.
  • Self-hosted n8n has a persistent volume, a backed-up N8N_ENCRYPTION_KEY, Postgres, and N8N_WEBHOOK_URL set.

The Stripe Subscription Starter includes the Checkout flow and the webhook route this guide extends. The StripeEvent and OpsEvent tables and the n8n forwarding are additions you make on top.

Product updates for developers shipping with Designyff kits.

Guides: Next.js, Stripe, and SaaS starter kits Professional website design for small businesses

Purchased source may be used in personal, commercial, and client projects. Do not redistribute or resell the original source. Terms of Service Privacy Policy

Designyff © 2026 | Starter kits for developers