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.
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
| Concern | Owner | Why |
|---|---|---|
| Signature check on Stripe events | App | It already has the raw body and the signing secret. |
plan, subscription id, access | App database | One source of truth for what a user can do. |
| Recording processed event ids | App database | Same transaction as the state change. |
| CRM contact, Slack alert, onboarding task | n8n | Changes often, owned by whoever runs ops. |
| Welcome sequence, newsletter tags | n8n → email tool | Marketing 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
400and nothing else runs. - If the state update throws, the
StripeEventinsert 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
200without side effects. afterruns 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.updatedis left out to keep the example short. Add it the same way when you want ops to hear about plan changes orpast_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:
- Webhook node. Method
POST. Under Authentication, choose Header Auth and create a credential with the nameX-Ops-Secretand the same value asN8N_OPS_WEBHOOK_SECRET. Set Respond to Immediately so the app gets a200as soon as the event is accepted. Use the Production URL forN8N_OPS_WEBHOOK_URL. The test URL only listens while you click “Listen for test event” in the editor (Webhook node docs). - 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). - Switch node on
{{ $json.body.type }}, one output per event type. - 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. SetN8N_ENCRYPTION_KEYyourself 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 replacesWEBHOOK_URL, deprecated from n8n 2.35.0) andN8N_PROXY_HOPS=1(reverse proxy docs). - Use Postgres for anything you depend on. The default is SQLite. n8n supports Postgres through
DB_TYPE=postgresdband theDB_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
- Run the app locally and forward Stripe events with
stripe listen --forward-to localhost:3000/api/stripe/webhook, as in the webhook guide. - Point
N8N_OPS_WEBHOOK_URLat the workflow’s test URL while you build it, then switch to the production URL. - Complete a test-mode Checkout. Check that the user row says
pro, aStripeEventrow exists, and theOpsEventrow hassentAtset. - Resend the same event from the Stripe Dashboard. The handler should answer
duplicate: true, and no second Slack message should appear. - 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, andN8N_WEBHOOK_URLset.
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.