MSPercury docs

Stripe integration & customer billing

End-to-end: from empty workspace to your first Stripe invoice, generated automatically from an accepted MSPercury quote. Covers one-time setup fees alongside monthly / yearly contract lines, VAT / USt-IdNr / EIN / GST-HST handling, and EU reverse-charge.

💡 Read this first. MSPercury does not use Stripe Connect or “on-behalf-of” billing. You connect your own Stripe account with your own API key. Money flows directly between you and your customer — MSPercury sees only the API response, never the money, never a fee, never a markup. We’re a pure data carrier.

1. Prepare your Stripe account

If you don’t have a Stripe account yet: stripe.com → sign up, complete KYC (ID + bank). 5–15 minutes. Already have one? Skip to step 2.

Before pushing any invoices through MSPercury, turn on Stripe Tax in the Stripe Dashboard:

  1. Stripe Dashboard → More → Tax → Settings → Get started
  2. Set your origin address (your business address, same as in MSPercury → Settings → Workspace)
  3. Add tax registrations — for DE MSPs: your German USt-IdNr (DEXXXXXXXXX) + 19 % rate auto-applied
  4. Optional: additional countries you’re registered in (Austria, Switzerland, …)

🟡 What Stripe Tax does for you. Correct VAT calculation on domestic invoices (DE 19 %), automatic reverse-charge for EU B2B recipients with valid VAT IDs (0 %, with annotation on the invoice), 0 % on non-EU invoices. Without Stripe Tax you have to handle all of this manually. Stripe Tax costs 0.5 % per invoiced transaction — on a €100 invoice that’s 50 cents.

Create a restricted key (instead of a secret key)

We strongly recommend a Restricted Key with minimal permissions, not your full secret key. Create one:

  1. Stripe Dashboard → Developers → API keys → + Create restricted key
  2. Name: MSPercury – Quote Push (or similar)
  3. Set permissions:
    • Customers: Write
    • Invoices: Write
    • Subscriptions: Write
    • Products: Write
    • Prices: Write
    • Tax IDs (Customers): Write
    • Tax Rates: Read
    • Charges, Payouts, Balance, Reporting: ❌ leave all on None
  4. Save → key starts with rk_test_… (test mode) or rk_live_… (live mode)

🔒 Why a restricted key? If your MSPercury workspace ever gets compromised (admin without 2FA, leaked password), an attacker holding a restricted key can’t trigger payouts, can’t shift refunds, can’t rotate API keys. They can only do what MSPercury already does: create invoices. A full secret key (sk_live_…) on the other hand allows essentially everything — avoid it where you can.

⚠️ Test mode first. Always do your onboarding with rk_test_…. Stripe test invoices don’t email real customers, don’t move real money, don’t book real accounting. Switch to rk_live_… only once your first test run is clean.

2. Set workspace tax fields

Before pasting the key into MSPercury, fill these two fields under Settings → Workspace:

  • Country: pick from the dropdown (e.g. Germany)
  • Tax-ID type: auto-fills when you change country (DE → eu_vat, US → us_ein, CA → ca_gst_hst)
  • VAT ID / Tax ID: your own DEXXXXXXXXX for German MSPs

💡 Where do these show up? They appear in the footer of every PDF (quote, agreement, CheckUp report), automatically with the locally-customary label: “USt-IdNr.: DEXXX” for German workspaces, “VAT ID: ATXXX” for Austrian, “EIN: 12-3456789” for US MSPs, “GST/HST: XXXXXXXXXRT0001” for Canadian. No more hardcoded German label. They’re also passed to Stripe so Stripe Tax can resolve the supplier jurisdiction.

3. Paste the Stripe key into MSPercury

  1. Open Settings → Integrations
  2. Paste the restricted key (rk_test_… for the first attempt) into the field
  3. Click Connect Stripe

What happens technically:

  • MSPercury calls stripe.accounts.retrieve() with your key to verify it’s valid and fetch account info
  • On success: key is AES-256-GCM encrypted at rest (same helper used for TOTP secrets), account display-name + ID + mode (test/live) are cached
  • On failure: Stripe error is shown verbatim (e.g. “No such account” → wrong key, “Insufficient permissions” → restricted key has too few scopes)

The status pill in the settings block shows:

  • 🟢 Connected + LIVE badge — ready for real invoices
  • 🟡 Connected + TEST badge — sandbox mode, no real emails / charges
  • Not connected — no key set

🔑 Rotate / replace the key. While connected you’ll see an expandable “Replace key” section. Paste a new key → the old one is overwritten, a fresh accounts.retrieve() validates it. If you suspect compromise (lost laptop, leaked repo): replace immediately here, then revoke the old key in the Stripe dashboard.

4. Set customer tax fields

Per customer (/customers/[id]/edit):

  • Country: ISO code from the dropdown
  • Tax-ID type: usually auto-filled by country (DE→eu_vat, US→us_ein, CA→ca_gst_hst)
  • Tax-ID value: the actual number such as DE123456789, 12-3456789, 123456789RT0001
  • Address: complete with street + postal + city

💡 Why this granular? Without country + type Stripe Tax can’t resolve the recipient jurisdiction. The result: 0 % VAT on invoices because Stripe doesn’t know whether it’s a domestic supply (DE 19 %) or an EU reverse-charge supply (0 %, recipient self-accounts). Both look identical to Stripe without country info.

⚠️ On the Stripe push. MSPercury first searches customers.search({ query: 'metadata.mspercury_customer_id:"…"' }) to see if the customer already exists in your Stripe account. If yes: address + tax ID are synced. If no: a fresh Stripe customer is created with tax_id_data and address. Pushing twice doesn’t create duplicates.

5. Build a quote and have it accepted

See Getting started for how to build a quote from scratch. What matters for Stripe billing is the billing period of each line item:

MSPercury periodStripe mappingWhen sent
One-timeInvoice (alone) OR InvoiceItem on the first sub invoice (mixed)Immediately, once
MonthlySubscription with interval=monthFirst invoice immediately, then every month
YearlySubscription with interval=yearFirst invoice immediately, then yearly

💡 Pick the period deliberately. Setup fees should be “one-time”. An RMM seat is “monthly”. A backup license with annual contract is “yearly”. The quote editor lets you pick per line item. If you mistype: post-accept editing is available (live record + dashboard MRR sync; the signed PDF stays frozen as audit reference).

Once the customer accepts the quote via the share page (or you mark it accepted manually), the violet Stripe block appears.

6. Stripe push — what actually happens

On an accepted quote’s detail page, the violet “Create Stripe invoice” block now shows. Before clicking, MSPercury tells you exactly what’ll be created:

Will be created in Stripe
├─ [Subscription]  6× monthly recurring + 2× one-time on first invoice
│                  — Stripe rotates follow-up invoices automatically
└─ [Subscription]  1× yearly recurring

Clicking the button executes:

  1. Stripe customer find-or-create (with address + tax-id-data)
  2. Per non-empty billing interval: subscriptions.create() with the matching items
  3. One-time lines: hung onto the first invoice of the first subscription via add_invoice_items — customer gets one email covering setup + first period
  4. Footer on every Stripe invoice: “MSPercury reference: Q-2026-XXXX” — links the invoice to its source quote by eye

What Stripe does autonomously after that:

  • Rotates the monthly / yearly follow-up invoices (no second API call from us)
  • Sends hosted-invoice emails using your branding (set logo + colour in the Stripe Dashboard!)
  • Lets the customer self-service: change payment method, cancel, download past invoices
  • Applies Stripe Tax logic on every recurring invoice (reverse-charge stays active as long as the tax ID is valid)

🟢 “Send now” toggle. For pure one-time quotes you can pick: leave invoice as draft in the Stripe Dashboard or send immediately. For subscription quotes the first invoice always sends — Stripe finalizes + emails it on subscriptions.create(). There’s no “draft subscription” concept in the Stripe API. If you don’t want that: don’t click, or pause / delete the subscription in the Stripe Dashboard afterwards.

🟡 Quantity constraint. Stripe’s subscription items only accept integer quantities. Quote lines with fractional quantities (“1.5 hours”) are rounded to the nearest integer (Math.round(...)). For hourly billing we recommend: convert to minutes (“90 minutes” with per-minute unit price), or add a separate “1.5h package” line.

7. EU reverse-charge

Stripe Tax applies the reverse-charge rule automatically, provided:

  • Your workspace is registered with country + VAT ID in DE/EU
  • The customer is in a different EU member state (so not domestic)
  • The customer has a tax-id-type eu_vat with a valid number (e.g. FR12345678901)

In that case:

  • Stripe invoice: 0 % VAT, annotation “Reverse charge — Art. 196 VAT Directive”
  • MSPercury quote PDF: yellow reverse-charge banner above the line items (Art. 196 VAT Directive / §13b UStG, in the customer’s language)

💡 What if the customer has no VAT ID? Then it’s not a B2B reverse-charge case, it’s a B2C supply — Stripe applies your local VAT rate (DE 19 %), and you must remit the VAT in the recipient country yourself (One-Stop-Shop / OSS scheme). That’s usually not what you want with MSP customers. If you need the reverse-charge path, actively ask for the VAT ID — and validate it via VIES before entering it.

⚠️ CH and UK. Switzerland (ch_vat) and the UK (gb_vat) are not EU — reverse-charge doesn’t apply. Configure Stripe Tax separately for those countries if you have many customers there.

8. Common pitfalls

SymptomCauseFix
Stripe rejects the key on saveWrong key or restricted key has too few scopesRe-check permissions in Stripe Dashboard, create a new restricted key with the scopes listed above
First invoice goes out at 0 % VAT despite domesticWorkspace has no country / tax-id-type setSettings → Workspace → fill country + tax-id-type
Reverse-charge isn’t appliedCustomer has no eu_vat type or empty tax-idCustomer edit → fill country + type + tax-id value, then push again
Duplicate invoice on second clickIdempotency not yet implemented (roadmap)Manually void the duplicate subscription / invoice in the Stripe Dashboard
”Customer has no email address”Email field on the MSPercury customer is blankCustomer edit → fill email — Stripe requires it for hosted-invoice delivery
Invoice sent but customer claims no mailStripe Tax or Stripe account hiccup with hosted-invoiceStripe Dashboard → Invoices → select that invoice → “Send again”
First sub invoice arrived but customer thinks that’s ALLThe fact that Stripe will auto-bill again next month/year wasn’t clearCustomise the hosted-invoice copy in Stripe Dashboard → Branding to mention “subscription, automatic recurring billing”

9. What MSPercury does not do

Clear scope split so you know which side owns what:

  • ❌ MSPercury does not generate tax certificates, year-end reports, VAT declarations — your tax advisor or accounting tool still does (DATEV connector is on the roadmap)
  • ❌ MSPercury does not validate VAT IDs — you’re responsible for VIES validation before you enter them
  • ❌ MSPercury does not void Stripe invoices — if a customer churns, pause / cancel the subscription in the Stripe Dashboard + void the open invoice
  • ❌ MSPercury does not archive Stripe invoices — they live in your Stripe account, you’re responsible for GoBD-compliant retention (Stripe has export features)
  • ❌ MSPercury does not handle refunds, chargebacks, disputes — all in the Stripe Dashboard
  • ❌ MSPercury does not know your Stripe balance, payouts, tax liability — we only see invoice IDs, never money flow

What MSPercury does do:

  • ✅ Turn every accepted quote into a Stripe invoice (or subscription) on your account
  • ✅ Sync customer master data + tax IDs into Stripe on every push
  • ✅ Stamp footer / metadata on every Stripe invoice with the source quote reference — you can jump back to the quote from the Stripe Dashboard
  • ✅ Keep the encrypted API key safe inside your workspace, never share it with third parties

10. Roadmap

Coming next:

  • Idempotency: a second click on “Create Stripe invoice” is detected, no duplicates
  • Stripe subscription IDs on the quote row: so /quotes/[id] can show “already pushed to Stripe — sub_…”, with a direct link into the Stripe Dashboard
  • Webhook sync: when a Stripe subscription is cancelled, the dashboard MRR auto-updates
  • Lexware Office, sevDesk, Polar.sh: alternative connectors for MSPs not on Stripe — see the roadmap

Missing something specific? Discord community, #stripe-billing channel. Lucas reads along.