Stripe
wFirma
Supabase
Biała Lista MF
KSeF
TypeScript

Stripe → wFirma — automatic VAT invoices after payment

Webhook + fallback, idempotency, Polish VAT Whitelist and KSeF — the full path from clicking „Buy” to the invoice landing in the customer's inbox.

Mateusz KozłowskiMateusz Kozłowski15 min read
Stripe → wFirma integration — automated invoicing

TL;DR — what we built here

The client buys a credit pack on a SaaS site. Pays by card via Stripe Checkout. Within seconds of payment confirmation:

  • credits land in their balance (idempotently — no duplicates),
  • wFirma issues a VAT invoice (B2B with NIP or personalized B2C),
  • if the client provided a NIP — the Polish VAT Whitelist (Biała Lista MF) pulls in the full company name and CEIDG address,
  • wFirma sends the PDF by email itself and (if enabled) issues the invoice in KSeF.

The entire integration consists of three Supabase edge functions and two database tables. Zero manual accounting work, zero risk of the client paying but not getting an invoice. Below is the full path — diagram, code, and pitfalls you won't find in the docs.

Context and problem

The client runs a pay-as-you-go SaaS (credits sold in packs). Up to now it worked like this: Stripe collected payment, the client got credits automatically, but the accountant issued invoices manually once a day from a Stripe CSV export. That was 30–60 minutes a day just on invoicing, plus a multi-day delay for B2B clients who need their invoice „yesterday”.

Architecture — full flow

Four sections, two confirmation paths (webhook + frontend fallback), two idempotency gates (credits and invoice separately), one MF integration.

┌─────────────────────────────────────────────────────────────────┐
│                       1. ZAKUP — KLIENT                          │
└─────────────────────────────────────────────────────────────────┘
   Klient: /cennik → wybiera pakiet → klika „Kup”
                  │
                  ▼
       create-checkout  (Supabase edge function)
       • billing_address_collection: required
       • tax_id_collection: enabled
       • customer_creation: always
                  │
                  ▼
       checkout.stripe.com  (Stripe hosted page)
       Klient wpisuje: karta, adres, [opcjonalnie] NIP + nazwa firmy
                  │
                  ▼
            💳 Stripe pobiera kasę


┌─────────────────────────────────────────────────────────────────┐
│         2. POTWIERDZENIE — DWIE RÓWNOLEGŁE ŚCIEŻKI               │
└─────────────────────────────────────────────────────────────────┘

   Stripe ──webhook──┐         Klient ──redirect──┐
                     ▼                            ▼
            stripe-webhook              /payment-success
            (edge function)             ↓
                  │                     verify-payment
                  │                     (edge function)
                  │                     ↓ (fallback dla webhook)
                  └─────────┬───────────┘
                            ▼
              IDEMPOTENCY GATE: stripe_session:{id}
              (czy ta sesja już była przetwarzana?)
                            │
                            ▼
              + dodaj kredyty do credit_balances
              + INSERT credit_transactions (type: purchase)
                            │
                            ▼
              .functions.invoke(„wfirma-create-invoice”)


┌─────────────────────────────────────────────────────────────────┐
│              3. wfirma-create-invoice  (edge fn)                 │
└─────────────────────────────────────────────────────────────────┘

   Idempotency: credit_transactions.wfirma_invoice_id IS NULL?
                            │
                            ▼
   Stripe.sessions.retrieve(sessionId, {expand: customer + tax_ids})
                            │
                            ▼
              ┌─────────────────────────┐
              │ czy NIP w tax_ids?      │
              └─────────────────────────┘
                  │ TAK            │ NIE / zła checksuma
                  ▼                ▼
   ┌──────────────────────┐   tax_id_type: „none”
   │ MF lookup            │   B2C imienna z danymi Stripe
   │ wl-api.mf.gov.pl     │
   │ → name + adres CEIDG │
   └──────────────────────┘
                  │
                  ▼
   buildContractor:
     name   = stripe (klient first), MF fallback
     adres  = stripe (klient first), MF fallback
     nip    = z tax_ids (zwalidowany)
                  │
                  ▼
   POST api2.wfirma.pl/invoices/add
   • paid: „1”, alreadypaid_initial: brutto
   • vat: 23%, paymentmethod: transfer
   • auto_send: „1”
                  │
              ┌───┴────┐
            OK│        │BŁĄD
              ▼        ▼
   UPDATE credit_      INSERT wfirma_
   transactions        invoice_errors
   wfirma_invoice_id   (request_payload do
   wfirma_invoice_     ręcznego retry)
   number


┌─────────────────────────────────────────────────────────────────┐
│                   4. PO STRONIE wFirma                           │
└─────────────────────────────────────────────────────────────────┘

   Faktura zapisana w wFirma
                  │
        ┌─────────┴─────────┐
        ▼                   ▼
   auto_send: „1”      type: „normal”
   wysyła PDF          KSeF auto-send
   na email klienta    (skonfigurowane
                       w panelu wFirma)

Key design decisions

  • 1

    Idempotency twice

    Sentinel stripe_session:{id} in credit_transactions (credits) and wfirma_invoice_id IS NOT NULL (invoice). Stripe may retry the webhook, the client may refresh /payment-success — nothing gets duplicated.

  • 2

    Best-effort wFirma

    stripe-webhook and verify-payment ALWAYS return 200, credits always get added. A wFirma error → log to wfirma_invoice_errors + alert for manual retry. The client doesn't lose credits because of an invoice bug.

  • 3

    Two parallel confirmations

    Stripe webhook (production-grade) + verify-payment (frontend fallback after redirect). Only one needs to work. The webhook may hang a few seconds — the client is already on /payment-success and sees their credits.

  • 4

    MF lookup by NIP

    The client types into Stripe Checkout — whatever they typed, we have (client first). Missing fields are filled in from the Polish VAT Whitelist (company name, CEIDG address). No key, no limit, free.

  • 5

    KSeF out of our scope

    wFirma sends to KSeF on its own (auto_send in the panel). We don't monitor KSeF status — by design. This shrinks the integration surface by 80%.

Database state — minimal model

Three tables are enough for this flow to run safely and be auditable:

credit_balances

Credit balance per user. Updated on every successful payment.

user_id, balance, updated_at

credit_transactions

Movement history. The stripe_session_id field has a UNIQUE constraint — that's our first idempotency gate. The wfirma_invoice_id field is only filled in after the invoice is successfully issued — the second gate.

id, user_id, type, amount, stripe_session_id (UNIQUE), wfirma_invoice_id, wfirma_invoice_number, created_at

wfirma_invoice_errors

A table only for errors. We keep the full request_payload and error_message — in case of failure, the accountant or developer can run a retry with the exact data, no guessing.

id, transaction_id, request_payload (jsonb), error_message, retried_at, created_at

Universal snippet — Stripe session → invoice in wFirma

The function below works 1:1 in Node.js, Deno and Supabase Edge Functions. No database, no framework — one file, ready to copy. It needs 5 environment variables and this Stripe Checkout config: billing_address_collection: 'required', tax_id_collection: { enabled: true }, customer_creation: 'always'.

/**
 * Stripe Checkout → wFirma (faktura VAT + Biała Lista MF)
 *
 * Wymagane ENV:
 *   STRIPE_SECRET_KEY      sk_live_... lub sk_test_...
 *   WFIRMA_ACCESS_KEY      z panelu wFirma → Ustawienia → Inne → API
 *   WFIRMA_SECRET_KEY      z panelu wFirma
 *   WFIRMA_APP_KEY         jednorazowy, do wzięcia z supportu wFirma
 *   WFIRMA_COMPANY_ID      ID Twojej firmy w wFirma (z /companies/get)
 */

import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2025-08-27.basil',
});

const WFIRMA_BASE = 'https://api2.wfirma.pl';
const VAT_RATE = 23;

// ─── Walidacja NIP (checksuma) ─────────────────────────────────────
function validateNip(nip: string): boolean {
  const d = nip.replace(/\D/g, '');
  if (d.length !== 10) return false;
  const w = [6, 5, 7, 2, 3, 4, 5, 6, 7];
  const sum = w.reduce((s, wi, i) => s + wi * +d[i], 0);
  const checksum = sum % 11;
  return checksum < 10 && checksum === +d[9];
}

// ─── Biała Lista MF — pobieranie nazwy firmy + adresu po NIP ───────
type MFSubject = {
  name?: string;
  residenceAddress?: string | null;
  workingAddress?: string | null;
  statusVat?: string;
};

async function lookupNipFromMF(nip: string): Promise<MFSubject | null> {
  const date = new Date().toISOString().slice(0, 10);
  const url = `https://wl-api.mf.gov.pl/api/search/nip/${nip}?date=${date}`;
  try {
    const res = await fetch(url, { headers: { Accept: 'application/json' } });
    if (!res.ok) return null;
    const data = await res.json();
    return data?.result?.subject ?? null;
  } catch {
    return null;
  }
}

// MF zwraca adres jako string "ULICA NUMER, 00-000 MIASTO"
function parseMFAddress(addr: string | null | undefined) {
  if (!addr) return null;
  const m = addr.match(/^(.+),\s*(\d{2}-\d{3})\s+(.+)$/);
  if (!m) return null;
  return { street: m[1].trim(), zip: m[2], city: m[3].trim() };
}

// ─── wFirma API — auth przez 3 nagłówki ────────────────────────────
function wfirmaUrl(path: string) {
  const params = new URLSearchParams({
    inputFormat: 'json',
    outputFormat: 'json',
    company_id: process.env.WFIRMA_COMPANY_ID!,
  });
  return `${WFIRMA_BASE}/${path}?${params}`;
}

async function callWfirma(path: string, body: unknown) {
  const res = await fetch(wfirmaUrl(path), {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Accept: 'application/json',
      accessKey: process.env.WFIRMA_ACCESS_KEY!,
      secretKey: process.env.WFIRMA_SECRET_KEY!,
      appKey: process.env.WFIRMA_APP_KEY!,
    },
    body: JSON.stringify(body),
  });
  const data = await res.json().catch(() => ({}));
  const code = (data as any)?.status?.code;
  if (!res.ok || code !== 'OK') {
    throw new Error(`wFirma ${code || res.status}: ${JSON.stringify(data)}`);
  }
  return data;
}

// ─── Główna funkcja: ze Stripe session do faktury w wFirma ─────────
export async function createWfirmaInvoiceFromStripeSession(sessionId: string) {
  // 1) Pobierz pełne dane sesji z customerem (fallback gdy customer_details puste)
  const session = await stripe.checkout.sessions.retrieve(sessionId, {
    expand: ['customer_details.tax_ids', 'customer', 'line_items'],
  });

  if (session.payment_status !== 'paid') {
    throw new Error(`Session ${sessionId} not paid`);
  }

  const customer = (typeof session.customer === 'object' ? session.customer : null) as Stripe.Customer | null;
  const details = session.customer_details;

  const stripeName = details?.name?.trim() || customer?.name || '';
  const email = details?.email || customer?.email || '';
  const a = details?.address || (customer?.address as Stripe.Address | null) || {};
  const stripeStreet = [a.line1, a.line2].filter(Boolean).join(' ').trim();
  const stripeZip = a.postal_code || '';
  const stripeCity = a.city || '';

  // 2) Wyciągnij NIP (PL prefix usuwany przez replace)
  const detailsTaxIds = (details as any)?.tax_ids as Stripe.TaxId[] | null;
  const customerTaxIds = (customer as any)?.tax_ids?.data as Stripe.TaxId[] | null;
  const taxIds = (detailsTaxIds?.length ? detailsTaxIds : customerTaxIds) || [];
  const polishVat = taxIds.find(t => t.type === 'eu_vat' || t.type === 'pl_nip');
  const nip = polishVat?.value?.replace(/\D/g, '').slice(-10);

  // 3) Zbuduj kontrahenta — klient first, MF jako fallback dla brakujących pól
  let contractor: Record<string, string>;
  if (nip && validateNip(nip)) {
    const mf = await lookupNipFromMF(nip);
    const mfAddr = parseMFAddress(mf?.residenceAddress ?? mf?.workingAddress);
    contractor = {
      name: stripeName || mf?.name || 'Klient',
      email,
      street: stripeStreet || mfAddr?.street || '—',
      zip: stripeZip || mfAddr?.zip || '',
      city: stripeCity || mfAddr?.city || '',
      country: a.country || 'PL',
      tax_id_type: 'nip',
      nip,
    };
  } else {
    // B2C — faktura imienna, bez NIP
    contractor = {
      name: stripeName || 'Klient',
      email,
      street: stripeStreet || '—',
      zip: stripeZip,
      city: stripeCity,
      country: a.country || 'PL',
      tax_id_type: 'none',
    };
  }

  // 4) Cena: Stripe daje brutto w groszach → liczymy netto
  const brutto = (session.amount_total ?? 0) / 100;
  const netto = (brutto / (1 + VAT_RATE / 100)).toFixed(2);
  const today = new Date().toISOString().slice(0, 10);
  const productName = session.metadata?.product_name || 'Usługa';

  // 5) Payload dla wFirma
  // ⚠️ STRUKTURA: invoices.invoice (BEZ indeksu "0"), count w formacie "1.0000"
  const payload = {
    invoices: {
      invoice: {
        type: 'normal',
        paymentmethod: 'transfer',
        paid: '1',
        alreadypaid_initial: brutto.toFixed(2),
        currency: 'PLN',
        disposaldate_form: 'sell_date',
        disposaldate: today,
        date: today,
        description: `Stripe ${sessionId}`,
        auto_send: '1',
        contractor,
        invoicecontents: {
          '0': {
            invoicecontent: {
              name: productName,
              unit: 'szt.',
              count: '1.0000',
              unit_count: '1.0000',
              price: netto,
              vat: String(VAT_RATE),
            },
          },
        },
      },
    },
  };

  // 6) POST do wFirma
  const result = await callWfirma('invoices/add', payload);
  const invoice = (result as any)?.invoices?.invoice;
  return {
    invoiceId: String(invoice?.id || ''),
    invoiceNumber: invoice?.fullnumber || invoice?.number || '',
  };
}

You call it from a Stripe webhook handler (event checkout.session.completed) or from a success-page endpoint after redirect. In production it's best to do both — with an idempotency gate on the database side (UNIQUE constraint on stripe_session_id).

Pitfalls you won't find in the docs

This was the hardest part to google. Each of these things cost a few hours of debugging, so I'm writing them down for later (and for you).

!

wFirma payload structure — invoices.invoice (WITHOUT „0” index)

Community SDKs and unofficial libraries show invoices.0.invoice — this does NOT work. wFirma expects an invoices.invoice object. The only place with indexes is invoicecontents (where „0” is a string key).

!

The count field must be „1.0000”

Without those zeros wFirma rejects with a generic INPUT ERROR. The same goes for unit_count. Decimal zeros are required.

!

paid: „1” + alreadypaid_initial

Without this, wFirma treats the invoice as unpaid and generates payment reminders to a client who already paid Stripe long ago. You have to set both fields.

!

Auth via 3 HTTP headers

accessKey, secretKey, appKey. You can generate accessKey + secretKey in the panel (Settings → Other → API), but appKey has to be obtained from wFirma support — a one-time ticket. Without it you get 401.

!

tax_id_collection requires customer_creation: 'always'

Without it (or without customer_update.name: 'auto' with an existing customer) Stripe throws 400 when creating the session. The error message doesn't say so directly.

!

Polish VAT Whitelist — no key, no limit

wl-api.mf.gov.pl/api/search/nip/{nip}?date=YYYY-MM-DD. Returns name, residenceAddress, workingAddress, statusVat. The address is one string „STREET NUMBER, 00-000 CITY” — you have to parse it.

!

gus_search in wFirma does NOT work

The wFirma docs suggest you can pass just a NIP and the gus_search flag will fetch the rest. In practice wFirma ignores this flag and requires name/zip/city. That's why we do the MF lookup on our side.

!

Stripe gives gross amounts in cents

session.amount_total is an integer in the smallest unit (e.g. 12300 = 123.00 PLN). You have to divide by 100 and compute net from VAT. wFirma wants price as net.

!

tax_ids in two places in the Stripe API

After Checkout, tax_ids live in session.customer_details.tax_ids (freshly typed). But if the customer already existed, they may be in customer.tax_ids.data. You need to check both.

!

The webhook should never return 5xx because of wFirma

Stripe will start retrying, and you'll create duplicate invoices (unless you have idempotency on the wFirma side — and you don't). Catch the wFirma error, log to wfirma_invoice_errors, return 200 to Stripe.

Results after rollout

~45 s

average time from payment to invoice in the customer's inbox

100%

of payments completed with an invoice without manual intervention (from day 1)

0

duplicate invoices and lost credits after 60 days in production

The accountant got 30–60 minutes a day back. B2B clients stopped writing „please send me an invoice”. The dev team got a pattern to reuse in future SaaS products.

Stack

  • Stripe Checkout — hosted page, billing address + tax ID collection
  • Supabase — Postgres + Edge Functions (Deno) for create-checkout, stripe-webhook, verify-payment, wfirma-create-invoice
  • wFirma API v2 — invoices/add with auto_send + KSeF
  • Biała Lista MF — wl-api.mf.gov.pl (public, no key)
  • TypeScript — shared types between the frontend and edge functions

Mateusz Kozłowski

Mateusz Kozłowski

Założyciel flowbiz · Ekspert automatyzacji procesów

Wdrażam automatyzacje, integracje i AI w średnich firmach na Pomorzu i w Kujawsko-Pomorskiem.

Więcej o autorze