Integrating Stripe in a French SaaS: VAT, Invoices, Webhooks
A complete guide to integrating Stripe in a French B2B SaaS: VAT handling, compliant invoices, secured webhooks, and subscriptions.
This article is written for a SaaS selling to French customers specifically (French VAT rules, French invoicing norms). The webhook security and idempotency patterns below apply to any Stripe integration, anywhere.
Stripe in France: what actually changes
Integrating Stripe into a French SaaS isn't just "copy-paste the US docs." There's 20% VAT to handle, compliant invoices to generate, and specific rules for B2B subscriptions.
Step 1: Stripe configuration
// src/lib/stripe.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2026-02-25.clover",
typescript: true,
});Products and prices
Create your plans in the Stripe dashboard with the right VAT rates:
// Example of programmatic creation
const product = await stripe.products.create({
name: "HeartCo Pro",
tax_code: "txcd_10103001", // SaaS - Software as a Service
});
const price = await stripe.prices.create({
product: product.id,
unit_amount: 3400, // €34 excl. VAT
currency: "eur",
recurring: { interval: "month" },
tax_behavior: "exclusive", // VAT added on top
});Step 2: Checkout Session
// src/server/api/routers/billing.ts
export const billingRouter = createTRPCRouter({
createCheckout: staffProcedure
.input(z.object({ priceId: z.string() }))
.mutation(async ({ ctx, input }) => {
const session = await stripe.checkout.sessions.create({
customer_email: ctx.session.user.email,
mode: "subscription",
line_items: [{ price: input.priceId, quantity: 1 }],
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
success_url: `${env.NEXT_PUBLIC_APP_URL}/dashboard/billing?success=true`,
cancel_url: `${env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
metadata: {
organizationId: ctx.session.user.organizationId,
},
});
return { url: session.url };
}),
});The key points for France
automatic_tax: { enabled: true }: Stripe calculates VAT automatically based on the customer's country. HeartCo doesn't enable this by default (VAT is fixed when prices are created), but it's the option to turn on if you bill individual consumers across several EU countries, where the rate depends on the customer's country of residence.tax_id_collection: lets the customer enter their intra-community VAT number, useful in B2B for reverse-charge VATmetadata: always includeorganizationId, the webhook needs it
Step 3: Secured webhooks
The webhook is the critical point. It's what updates your database once a payment is confirmed.
// src/app/api/stripe/webhook/route.ts
import { headers } from "next/headers";
import crypto from "crypto";
export async function POST(req: Request) {
const body = await req.text();
const headersList = await headers();
const signature = headersList.get("stripe-signature")!;
// HMAC verification: MANDATORY
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object;
const orgId = session.metadata?.organizationId;
if (!orgId) break;
await db.subscription.update({
where: { organizationId: orgId },
data: {
plan: "PRO",
stripeSubscriptionId: session.subscription as string,
status: "ACTIVE",
},
});
break;
}
case "customer.subscription.deleted": {
// Downgrade to FREE
const sub = event.data.object;
await db.subscription.updateMany({
where: { stripeSubscriptionId: sub.id },
data: { plan: "FREE", status: "CANCELED" },
});
break;
}
}
return new Response("OK", { status: 200 });
}The critical security rule
Never compare signatures with ===:
// ❌ VULNERABLE: timing attack
if (computedSignature === receivedSignature) { ... }
// ✅ SECURE: constant-time comparison
crypto.timingSafeEqual(
Buffer.from(computedSignature),
Buffer.from(receivedSignature),
);The Stripe SDK does this for you via constructEvent, but if you ever verify a signature manually, always use timingSafeEqual.
The trap you discover in production: replayed webhooks
Stripe guarantees at-least-once delivery, not exactly-once. On a timeout or network error, the same event can arrive twice. Without protection, a checkout.session.completed webhook processed twice can create a duplicate transaction.
The fix is a dedicated table with a unique constraint on the Stripe event ID, inserted before any business logic runs:
// Strict idempotency: an atomic create, the unique constraint does the work
try {
await db.stripeWebhookEvent.create({
data: { stripeEventId: event.id, type: event.type },
});
} catch (e: unknown) {
// Prisma unique-constraint violation (P2002) = already processed
const isDuplicate =
typeof e === "object" &&
e !== null &&
"code" in e &&
(e as { code: string }).code === "P2002";
if (isDuplicate) return new Response("OK", { status: 200 });
throw e;
}A classic if (already seen) return check has a race window between the read and the write. The atomic create on a unique column doesn't: the second call fails cleanly on the constraint, and duplicate processing never gets through.
Step 4: Compliant invoices
For a French B2B SaaS, your invoices need to include:
- A sequential number (INV-2026-001)
- VAT breakdown (excl. VAT + VAT + incl. VAT)
- Legal disclosures (SIRET, VAT number)
- Issue date and payment date
Stripe generates invoices automatically for subscriptions. Turn on Stripe Invoicing and configure your legal information in the dashboard.
A Stripe invoice that's compliant on these points is not the same thing as an electronic invoice under the 2026 reform (Factur-X, approved platforms). If your B2B customers are French companies, the receiving obligation is already in force as of this article's last update: see Electronic invoicing 2026 for a B2B SaaS in France for what that actually changes.
Stripe France checklist
-
automatic_taxenabled on every Checkout Session -
tax_id_collectionenabled for B2B customers - Webhook verified by HMAC signature
- Legal information in the Stripe Dashboard (SIRET, VAT)
- Downgrade handled on subscription expiry
- Confirmation emails configured (Stripe or custom)
- Test mode validated before going live
Payments are the most sensitive part of your SaaS. Take the time to get it right: your customers (and your accountant) will thank you.
Go further
Related articles
Electronic Invoicing 2026 for a B2B SaaS in France
Official timeline, PDP and e-reporting vocabulary, generating a Factur-X, and the role of a connector like iopole for a French B2B SaaS.
ReadGDPR for a French B2B SaaS: The Complete Checklist, No Myths
What you actually have to do to be GDPR-compliant as a French B2B SaaS, and what compliance agencies sell you that you don't need.
ReadSteering Claude Code with CLAUDE.md on a Multi-Tenant SaaS
The real rules that govern this SaaS with Claude Code: what goes in CLAUDE.md, what doesn't, and what's actually shipped at purchase.
ReadReady to launch your SaaS?
HeartCo Starter includes everything you need: auth, payments, AI, mobile, audited security. Starting at $219.