Traffic charts answer “did anyone show up?” Revenue answers “did anyone pay?” When those answers live in different products, Plausible in one tab, Stripe in another, a Gumroad email at 6 a.m. founders build folk theories about which post worked. A custom webhook path that lands revenue in the same view as traffic is how small teams stop merging CSVs every Sunday.
This guide covers the webhook ROI guides. It is not a data warehouse. It is operational alignment: pageviews, UTMs, and money events in one project timeline so launch day and the week after share one vocabulary.
The stack in one sentence
Browser or server records visits → billing provider fires webhooks → your analytics ingests both → you read overlays and pins, not exports.
The “custom” part matters when you are not 100% Stripe: Lemon, Paddle, Gumroad, self-serve invoicing, or a script that posts JSON when a bank transfer clears. The integration pattern is the same: signed HTTP POST, normalized payload, dedupe key.
Why webhooks beat polling dashboards for ROI
Polling Stripe every five minutes from a cron job works until:
- You forget which server ran the cron.
- Rate limits bite on launch day.
- Refunds and disputes arrive out of order.
- You want sub-hour correlation during a livestream.
Webhooks push facts at the time they happen. Analytics stores them beside visits that happened minutes earlier. That is enough for:
- “Newsletter sent at 9:00; traffic hump 9:05; first checkout 9:22.”
- “PH day traffic 4×; new revenue 2×, conversion soft, not tracking broken.”
Perfect attribution is not the goal. Directionally honest ROI on a single domain is.
Minimum payload you should send
Whether Stripe sends it for you or you POST custom JSON, normalize to:
| Field | Purpose |
|---|---|
amount / currency | Money in one currency per report |
event_type | checkout.completed, subscription.started, refund |
external_id | Idempotency, same charge twice does not double revenue |
occurred_at | Server time, ISO-8601 |
customer_metadata | UTMs, landing_path, plan slug if you have them |
Optional but high value: product_id, is_renewal flag, campaign from your app DB.
Strip PANs and secrets. Webhooks are logs, not vaults.
First-party vs provider webhooks
Provider webhooks (Stripe, Paddle, etc.): verify signatures, map event types, filter test mode. See Stripe checkout tied to landing pages for metadata habits.
Custom webhooks: your backend posts when:
- A non-Stripe processor confirms payment
- Enterprise wire hits and ops clicks “mark paid”
- Marketplace take settles on your ledger
Same ingestion endpoint; your code owns shape. Document one internal OpenAPI snippet so future you does not invent a second format.
Same view as traffic: what the UI should show
Founders need three surfaces:
- Daily or hourly chart: visitors and new revenue stacked or dual-axis.
- Event feed: sparse rows for launches (“$49 · /pricing · utm_source=twitter”).
- Pins: launch week, newsletter week, pricing change (marketing pulse guides).
Live launch optional: recent revenue rows beside live visitor dashboard. Morale + sanity check that webhooks are alive.
Hybrid billing is normal
Stripe for subscriptions plus Paddle for EU VAT plus Gumroad for a template pack is not exotic. It is three webhook sources into one normalization layer. Paddle webhook alongside Stripe covers wiring without double-counting.
Rules:
- One canonical “new revenue” definition per retro.
- Renewals excluded from acquisition charts unless you run renewal campaigns.
- Refunds subtract in the window they land, or you accept gross-only and note it.
Weekly automation without Zapier spaghetti
Not every team wants Zapier in the critical path. A founder cron script that hits your stats HTTP API on Monday morning is valid architecture. Weekly founder script over the stats API covers auth, idempotency, and what to print in Slack.
Marketing events that are not money
Sometimes you need a timeline marker: “paid newsletter sponsorship went live,” “billboard photo posted.” A marketing webhook creates a pin or annotation without faking revenue. Mark a campaign on pulse explains shape and abuse avoidance.
Ordering and race conditions
Classic bug: checkout webhook arrives before the success page pageview. Visitor looks “direct” or missing. Mitigations:
- Pass UTMs into Checkout metadata at session create (first-touch UTM metadata).
- Fire a client
purchaseevent on success page as backup. - Read pageview after webhook ordering before you rewrite the whole stack.
Security checklist
- HTTPS only; rotate endpoint secrets quarterly.
- Verify HMAC or provider signatures; reject unsigned prod traffic.
- Separate test and live projects or namespaces.
- Log raw body hashed, not full card data.
Failure modes on launch day
| Symptom | Likely cause |
|---|---|
| Traffic up, revenue flat | Webhook URL wrong env; trials; broken checkout |
| Revenue without traffic | Missing tracker on marketing site; ad blockers on success page only |
| Double revenue | No idempotency on external_id |
| Wild currency | Mixed EUR/USD without normalization |
Watcher role from when live stats distract should include webhook heartbeat, not whole-team Stripe refresh.
How this connects to RPV and UTMs
RPV = revenue ÷ visitors for a window (revenue per visitor guides). Webhooks supply revenue; tracker supplies visitors. Same dates or lies.
UTMs name the spike (UTM attribution). Webhooks carry metadata copied at checkout time, not perfect multi-touch, enough to rank channels.
Implementation phases (realistic)
Week 1: Stripe live webhook → daily revenue line + manual pin on launch.
Week 2: Metadata on checkout; landing path in payload.
Week 3: Second provider or custom POST for edge cases.
Week 4: Monday script to Slack with visitors, revenue, top source.
Stop when decisions get faster. Do not build Snowflake cosplay.
Reconciliation habit (15 minutes monthly)
Export gross new revenue from Stripe (and Paddle if applicable) for the calendar month. Compare to analytics total for the same dates and definition (new vs renewal). Tolerance band of a few percent is normal, timing at month boundaries, refunds, and currency rounding explain most gaps. Double digits mean broken webhooks, test pollution, or renewal leakage into acquisition. Write the delta in a single spreadsheet cell; trends matter more than one angry afternoon.
When custom webhooks beat native integrations
Native Stripe integrations are table stakes. Custom POST shines when:
- You sell through a marketplace that emails you CSVs unless you push HTTP yourself.
- Enterprise contracts hit your ledger before any card network sees them.
- You run a short-lived promo code database in Postgres and want
promo=LAUNCH50beside traffic without waiting for a vendor roadmap.
Keep payloads small and documented. Future contractors should ingest from one README, not oral tradition.
More guides
- Paddle webhook alongside Stripe
- Weekly founder script on stats API
- Marketing webhook to mark campaigns
One sentence for your runbook
“Revenue rows come from signed webhooks; visitor rows come from the tracker; never merge spreadsheets on Sunday again.” Pin that above your .env example so contractors do not rebuild exports. If a number looks wrong, check webhook dedupe before you blame marketing.