You started on Stripe. EU customers asked for VAT sanity. A template line still sells on Gumroad. Now Paddle handles some checkouts and Stripe handles the rest, and your analytics shows either half the money or double the glory depending on which webhook you forgot.
Part of the webhook ROI guides. Read the main guide on unified webhook revenue first; this is the hybrid Paddle + Stripe wiring guide for founders who refuse a billing migration weekend.
Why hybrid billing persists
Common honest reasons:
- Stripe for US subscriptions and complex billing objects.
- Paddle as merchant of record for EU/UK tax simplicity.
- Gumroad/Lemon for one-off digital SKUs you will not port.
- Legacy Stripe customers while new EU rows go Paddle.
Marketing stays one domain; money leaves through multiple pipes. ROI view must aggregate pipes, not pick a favorite.
One normalization layer
Do not send Stripe and Paddle to different analytics projects unless you enjoy schizophrenia. Ingest both into one stream with shared fields:
source: stripe | paddle | gumroad | custom
external_id: provider event or transaction id
amount_minor: integer cents
currency: ISO code
event_type: new_sale | renewal | refund | subscription_started
is_test: boolean
occurred_at: timestamp
metadata: { utm_source, landing_path, plan, ... }Map provider-specific names in code once. Dashboards read normalized rows only.
Stripe-specific habits
- Listen to
checkout.session.completedfor new acquisition; exclude renewals viabilling_reasonor invoice flags (one-time vs subscription attribution). - Copy UTMs to Customer or Session metadata at create time (first-touch UTM).
- Filter
livemode: falseaggressively.
Paddle-specific habits
- Verify Paddle signature on webhook endpoint; reject replayed bodies.
- Map
transaction.completed(and equivalents) to yournew_saletype; read Paddle docs for your API version, names drift. - Paddle as MoR means gross may already net tax; pick one reporting convention (gross checkout vs seller earnings) and stick in footnotes.
- Trials and proration events differ from Stripe; maintain a translation table in repo, not in your head.
Double-counting traps
| Trap | Fix | |
|---|---|---|
| Customer pays Paddle after Stripe trial | Migration script fires both | Dedupe on customer_email + window or single subscription_id canonical |
| Refund in Stripe, chargeback in Paddle | Two negative events | Negative amount_minor; same external_id family |
| Test purchases in live dashboard | Filter is_test | Separate API keys per env |
| Internal admin comp | Manual webhook | Tag event_type: comp excluded from RPV |
Attribution when checkout hosts differ
Stripe Checkout may live on billing.yourapp.com; Paddle overlay may feel “off-site.” UTMs still work if marketing landings set cookies/sessionStorage and you copy tags into both providers’ metadata at redirect to payment.
If Paddle checkout cannot carry metadata, pass a passthrough or custom data field with session_id from your app; join server-side before posting analytics webhook.
Success page on your domain should still fire a pageview, ordering issues in pageview after webhook.
Reading hybrid revenue beside traffic
Overlay daily visitors with daily new revenue (all sources). Segment by source only in retro, not during firefighting.
Launch live view: one revenue feed sorted by time, Stripe and Paddle rows interleaved (live launch dashboard).
RPV with multiple currencies
Convert to one reporting currency at ingest (ECB daily rate) or split RPV per currency (messy). Micro-SaaS default: pick USD or EUR, convert on webhook, note assumption in weekly script output (weekly stats API script).
When to split projects instead
Split analytics projects only if:
- Two truly separate brands on one repo (see two products one domain).
- Legal separation between entities.
Otherwise unified.
Migration week playbook
Before: Document which customers on which provider; freeze pricing changes.
During: Run both webhooks; tag events migration_stripe_to_paddle in metadata; exclude from acquisition charts or show separately.
After: Single source per cohort; archive old endpoint with 410 after 30 days replay window.
Pin migration week in marketing pulse guides so traffic dips from “we changed checkout” are not blamed on content.
Gumroad/Lemon as third pipe
Same normalization. Digital product ratios in Gumroad guides. Hybrid digital + SaaS is normal; source field scales.
Support tickets that hybrid billing creates
Customers do not care which MoR processed them; they email billing@ with one screenshot. Train support to note provider in ticket tags. Analytics then explains revenue spikes that do not match “we only changed the homepage.”
Common confusions:
- Receipt brand says Paddle while login email mentions Stripe, migration cohort not documented.
- VAT line items make Paddle gross look “higher” than Stripe net, reporting convention not shared with team.
- Refund path differs by provider, negative webhook arrives days later; weekly script should not declare victory on day one.
A single ROI view reduces finger-pointing between “marketing broke checkout” and “engineering broke webhooks” because both see the same timestamped feed.
Load testing webhooks on rehearsal day
Replay a handful of signed sample payloads from Stripe and Paddle docs into staging ingest. Confirm:
- Signature verification passes for both.
external_iddedupe works when Stripe test clock fires twice.- Paddle passthrough metadata survives the field mapping you chose.
Do not load-test production endpoints with imaginary money on launch morning. Rehearsal is boring; launch morning is not the time to learn HMAC.
Investor and board reporting
Board decks want one revenue line. Behind the slide, keep a by_source table for yourself. Explaining “Paddle is EU MoR” once is fine; explaining it every month because charts disagree is not. Hybrid stack is a business choice; unified webhook ingest is how you keep the story one sentence.
Checklist
- One ingest endpoint, two verified signatures
- Idempotency keys per provider
- Renewal exclusion policy written
- Test mode isolated
- Metadata passthrough tested on Paddle + Stripe
- Monday script sums
sourcebreakdown
Sandbox and production isolation
Stripe test mode and Paddle sandbox must never share an ingest endpoint with live traffic without loud labeling. Founders routinely demo “the dashboard” to a friend using test cards; if those events land in production aggregates, your launch retro is fiction. Use separate API keys, separate projects, or hard is_test filters enforced in code, not commented out “for now.”
Document which Paddle environment maps to which URL in the same README where you store webhook secrets. Six months later you will not remember.
Observability: one Slack channel for webhook health
Create #billing-ingest (private if needed). Post only machine messages: “stripe checkout.completed $49”, “paddle transaction.completed €39”, “dedupe skipped id xyz”. Humans mute the channel; watchers unmute launch week. When traffic surges and channel goes silent, you have a smoking gun before Twitter does. This complements unified ROI charts, feeds confirm pipes, charts confirm trends. Keep messages short; you are not building a second analytics product in chat.
Migration week
When moving legacy Paddle subs to Stripe, tag dual webhook week billing-migration in notes so acquisition metrics do not look like a launch. Keep both webhooks active until the last Paddle sub churns or migrates. Label revenue rows with processor=paddle|stripe in your head when you scan weekly totals.