Start here
Payment webhooks
The final step. Point your processor at the matching NumberHill endpoint; each one parses that processor's payload, pulls the visitor ID out of metadata, finds the visitor's first touch, and credits that channel.
Endpoints
| Endpoint | Processor | Events handled |
|---|---|---|
| /api/webhooks/stripe | Stripe | payment_intent.succeeded, checkout.session.completed, checkout.session.async_payment_succeeded, invoice.payment_succeeded, charge.refunded, charge.dispute.funds_withdrawn, charge.dispute.funds_reinstated |
| /api/webhooks/lemonsqueezy | LemonSqueezy | order_created, subscription_payment_success, order_refunded |
| /api/webhooks/polar | Polar | order.paid, order.refunded |
| /api/webhooks/shopify | Shopify | orders/paid, refunds/create |
| /api/webhooks/dodo | Dodo Payments | payment.succeeded, payment.refunded, refund.succeeded |
Only these events are recorded. Anything else is acknowledged and ignored, so subscribing to orders/create or order.created can never book an order that was never paid for. Stripe's Checkout Session and Payment Intent events both record against the payment intent, so one purchase stays one row even with both subscribed.
Setting one up
Using Stripe as the example — the shape is the same everywhere:
- In NumberHill: Dashboard → Settings → Payment webhooks. Copy your site's URL — it already has the right
?site=on the end. - Stripe Dashboard → Developers → Webhooks → Add destination, and paste that URL.
- Select every event listed for your processor above, then save. It is worth being literal about this: the events are not interchangeable, and each one you leave out fails quietly in a direction that flatters you. Skip
charge.refundedand refunds never reduce your revenue. Skip the dispute events and chargebacks never do either. Skipinvoice.payment_succeededand every subscription renewal after the first goes unrecorded. - Open the destination you just created and reveal its signing secret (
whsec_…). It is not shown while you are creating it. - Paste that secret back into Settings → Payment webhooks → Add secret.
Why the URL has ?site=
The endpoint has to know which site a payment belongs to before it can decide whose signing secret to check it against. The ?site= value is your site's tracking id — the same one in your script tag, and public for exactly that reason. It selects; it never authorises. Forging a payment still requires the signing secret, which never leaves the database.
Signing secrets
Secrets are stored per site, not per deployment. Every Stripe account issues its own, so one shared secret could only ever verify a single merchant. Paste yours in Settings; the dashboard shows only the last four characters afterwards and never returns the full value to the browser.
No secret = webhooks rejected
A processor with no secret configured for your site is rejected outright in production. That is deliberate: the alternative is accepting any well-formed POST, which would let anyone write fabricated revenue into your reports. Local development still accepts unsigned payloads so you can test withcurl.Refunds, chargebacks and duplicates
- Refunds are stored as a negative amount against the original transaction, so revenue per channel nets out on its own.
- Chargebacks work the same way. A dispute that takes the money back is recorded as a negative row, and one reversed in your favour puts it back — neither counts as a new customer, so your customer count and conversion rate are unaffected either way.
- Payments are keyed on
(site, processor, external_id), so a webhook delivered twice updates the same row instead of double-counting, and no other site's payment can ever collide with yours. - A payment with no matching visitor is still recorded — it just lands under
Unattributed.
Subscriptions
A subscription's first payment carries whatever metadata you set at checkout. Renewals do not: Stripe generates them months later with no Checkout Session involved, and session metadata is not copied onto them. Set the visitor ID on subscription_data.metadata as well and it lands on the Subscription itself, where every future invoice can carry it back.
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: "price_123", quantity: 1 }],
success_url: "https://example.com/thanks",
metadata: { numberhill_visitor_id: visitorId },
// Without this, month 2 onward arrives with no visitor id and every
// renewal lands in "Unattributed" — so the channel that won the customer
// gets credited with one month of their lifetime value.
subscription_data: {
metadata: { numberhill_visitor_id: visitorId },
},
});Test mode
Test-mode events are acknowledged and ignored in production, so the test payments you fire while setting this up never become revenue you can't remove. They appear in the delivery log below as ignored — test-mode event, which is how you confirm the URL and secret are right without polluting your numbers. Against a local development server they are recorded, so you can watch the whole path work end to end.
Checking it works
Settings → Payment webhooks shows the recent delivery history for each processor: when the last one arrived and what happened to it. A run of invalid signature means the stored secret doesn't match the endpoint you created — the single most common setup mistake, and the one that otherwise looks exactly like having made no sales.
Testing locally
stripe listen --forward-to "localhost:3000/api/webhooks/stripe?site=<tracking_id>"
stripe trigger payment_intent.succeeded