Skip to main content
All articles
5 min read

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 VAT
  • metadata: always include organizationId, 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_tax enabled on every Checkout Session
  • tax_id_collection enabled 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

Share

Ready to launch your SaaS?

HeartCo Starter includes everything you need: auth, payments, AI, mobile, audited security. Starting at $219.

30-day money-back guarantee