Stripe webhooks to Meta, Google and LinkedIn conversions: Purchase and Subscribe without double counting
By Canada Site Ops · October 18, 2026
For a B2B SaaS company, the most valuable conversion is the one that happens in Stripe: a customer pays. Your ad platforms usually never see it. The browser pixel fires on a "thanks" page that half your buyers never load, annual contracts are paid by invoice weeks later, and renewals happen with nobody on the site at all. Stripe already knows about every one of those payments and announces each with a webhook. This guide shows how to turn those webhooks into Meta Purchase and Subscribe conversions (plus Google and LinkedIn previews), what to map, and the two traps that make most homemade integrations double count or under count.
Status, honestly: server-track-lab delivers to the Meta Conversions API with your own Pixel/Dataset ID and access token. Google and LinkedIn are dry-run previews (no live delivery yet): we build and validate the exact request and show it in the dashboard, but send nothing. Billing for server-track-lab itself is Stripe test mode only.
The short version
- In the server-track-lab dashboard, open the Stripe source card and press Create endpoint URL. You get a private URL like
/api/v1/sources/stripe/src_…with 24 random characters. - In Stripe, open Workbench → Webhooks → Create an event destination, choose Your account, payload format Snapshot, and select
checkout.session.completedandinvoice.paid, pluscheckout.session.async_payment_succeededif you accept bank debits or other delayed payment methods (recommended). Choose Webhook endpoint and paste the URL. - Click Reveal secret on the new endpoint, copy the
whsec_…signing secret, and paste it into the card. It's encrypted at rest; afterwards you only ever see the last 4 characters. - Press Send sample. A sample checkout is signed with your secret and runs through the same code path a real Stripe delivery takes. It appears in Last 20 events labelled Stripe, with the Meta result and the Google/LinkedIn requests we would send.
Start in a Stripe sandbox (test mode). Sandbox events arrive with livemode: false and are stored as test events, so they don't use your quota. Meta only receives test events while a Meta test event code is saved (they land in Events Manager → Test events); without one, the Meta column shows the request we would send.
Why only two (or three) events
Stripe emits dozens of event types for a single sale: payment_intent.succeeded, charge.succeeded, customer.subscription.created, invoice.finalized and more. Subscribing to all of them is the most common reason conversions get counted three or four times. For conversion tracking you want exactly one signal per moment of value:
checkout.session.completed: the customer finished Stripe Checkout. It carries the total, the customer's email and yourclient_reference_id, which is usually how you tie the payment back to a user in your app.invoice.paid: an invoice was paid. This covers renewals, plan upgrades, sales-led deals billed by invoice, and subscriptions your backend created through the API without Checkout.checkout.session.async_payment_succeeded(recommended if you accept bank debits or other delayed payment methods): with those methods, Checkout completes before the money arrives (payment_status: "unpaid"), and Stripe sends this event when the payment succeeds later.
Anything else that reaches the endpoint gets a 200 with {"status":"ignored","reason":"unsupported_event_type:…"}, so Stripe doesn't retry it and you can see why nothing was forwarded.
How each event is mapped
| Stripe event | Conversion | event_id |
|---|---|---|
checkout.session.completed, mode payment | Purchase | stripe_cs_ + Checkout Session id |
checkout.session.completed, mode subscription | Subscribe | stripe_sub_ + subscription id |
| checkout.session.async_payment_succeeded (delayed payments) | same as the Checkout | same key as the Checkout |
| invoice.paid, first invoice of a subscription | Subscribe | stripe_sub_ + subscription id |
| invoice.paid, renewal or any other invoice | Purchase | stripe_in_ + invoice id |
The value comes from amount_total (Checkout) or amount_paid (invoice), and the currency is uppercased. The event time is the Stripe event's created time, not the moment it reached us, so a delivery Stripe retried an hour later still lands at the right time. action_source is system_generated.
Trap 1: the first invoice of a new subscription
When someone subscribes through Checkout, Stripe sends both checkout.session.completed and an invoice.paid for the first invoice (its billing_reason is subscription_create). Count both and every new customer looks like two conversions. Count only Checkout and you miss subscriptions created through the API. Stripe also doesn't promise delivery order, so you can't simply "skip the invoice if the checkout already arrived".
Our rule: both events map to the same event_id, stripe_sub_<subscription id>. Whichever arrives first is forwarded as Subscribe. The second one returns deduplicated inside the 72-hour dedup window and is never sent again. Order doesn't matter, Checkout and API-created subscriptions both count exactly once, and every later renewal is its own Purchase. A free-trial start counts once as Subscribe with value 0 (the trial Checkout and its $0 first invoice share the same id), and the first paid invoice after the trial is a Purchase. Other $0 invoices (100% coupons) are ignored. Checkout sessions still marked payment_status: "unpaid" (delayed payment methods such as bank debits) are ignored too: for a subscription, the first paid invoice then counts as the Subscribe, and when you also send checkout.session.async_payment_succeeded, it is mapped like a paid Checkout with the same key (stripe_cs_ + session id for one-time payments, stripe_sub_ + subscription id for subscriptions), so a delayed payment counts exactly once, even if Stripe delivers that event twice. Without it, one-time delayed payments aren't counted.
Trap 2: zero-decimal currencies
Stripe sends amounts in the currency's smallest unit: 4900 means CA$49.00. Most homemade integrations divide everything by 100, which turns a ¥5,000 order into ¥50. Stripe's currency docs list the zero-decimal currencies, whose amounts are already whole units: BIF, CLP, DJF, GNF, JPY, KMF, KRW, MGA, PYG, RWF, VND, VUV, XAF, XOF and XPF. We keep those as-is. Two special cases, ISK and UGX, are represented by Stripe as two-decimal values (500 means 5), so they are divided by 100 like everything else. Three-decimal currencies (BHD, JOD, KWD, OMR, TND) are divided by 1000 and rounded to two decimals, the precision our pipeline carries.
Retries and duplicates
Stripe retries a delivery that didn't get a 2xx, for up to three days in live mode, and says endpoints may occasionally receive the same event more than once, and that in some cases two separate Event objects (different evt_ ids) are sent for the same object. That's why the event_id is built from the Stripe object, not the event: the Checkout Session, the subscription or the invoice. A retry, or a second Event for the same session or invoice, maps to the same event_id and shows as deduplicated instead of a second Purchase. Two different renewals are two different invoices, so both count. We also answer quickly, as Stripe recommends: the signature check and mapping happen first, we return 200 {"received":true,"status":"queued"}, and forwarding to Meta runs in the background. The outcome is shown as the source's last delivery in the dashboard and as a normal row in the event log.
Signature verification
The endpoint has no API key; Stripe's signature is the authentication. Each delivery has a Stripe-Signature header with a timestamp t and one or more v1 signatures. We compute HMAC-SHA256 of t + "." + raw body using your signing secret and compare it in constant time with every v1 value. Any match passes, which keeps deliveries working while you roll the secret in Stripe. Timestamps more than 5 minutes old are refused, the same default tolerance Stripe's libraries use. A missing, stale or wrong signature returns 400 invalid_signature and nothing is stored. If you see that in Stripe's Event deliveries tab, the secret saved in the dashboard doesn't belong to that endpoint: copy it again from the endpoint page and save.
Getting a good match rate
A webhook has no browser cookies, so matching relies on what Stripe knows about the buyer. We send:
- Email from
customer_details.emailorcustomer_email, normalized and SHA-256 hashed exactly as for/api/v1/track. - Phone (when it's in E.164 form) and country from the customer details.
- external_id from
client_reference_id, otherwisemetadata.external_idormetadata.user_id, otherwise the Stripe customer id. If your other events use your app's user id as external_id, setclient_reference_idto that id when you create the Checkout Session, and the payment joins the same person. - Click ids: put
fbp,fbc,fbclid,gclidorli_fat_idin the Checkout Session'smetadata(or the subscription's) when you create it. We pass them through. This is the biggest single improvement for ad attribution, because it ties the payment to the click that started it.
If the browser pixel also fires on your thank-you page, Meta can only merge the two when they share an event id. For subscriptions that's possible: the server event id is stripe_sub_ plus the subscription id, which your success page can look up from the Checkout Session, so call fbq('track','Subscribe',{},{eventID:'stripe_sub_'+subscriptionId}). For one-off payments the server event id is stripe_cs_ plus the Checkout Session id. Add ?session_id={CHECKOUT_SESSION_ID} to your Checkout success_url (Stripe fills it in) and fire fbq('track','Purchase',{value:…,currency:…},{eventID:'stripe_cs_'+sessionId}) on the success page, and Meta merges the two. Or drop the browser Purchase and let the webhook be the source of truth. See the event_id dedup guide for the details.
What a forwarded event looks like
A paid CA$49 Checkout session becomes this /track event inside the pipeline:
{"event_name": "purchase", "event_id": "stripe_cs_cs_live_a1…", "timestamp": "2026-10-18T06:12:09Z",
"value": 49, "currency": "CAD", "action_source": "system_generated",
"user": {"email": "buyer@example.com", "external_id": "user_42", "country": "CA"}}
Meta receives a Purchase with hashed em, external_id and country. The Google column shows the Data Manager events:ingest request and the LinkedIn column the conversionEvents request we would send, both dry-run. Click view on the event row to read them.
Checklist before switching Stripe to live mode
- The sandbox endpoint shows Stripe rows with Meta
sent ✓ (Test events)while a Meta test event code is saved. - One sandbox subscription produced exactly one Subscribe (the checkout and first invoice deduplicated).
- Create a separate live-mode event destination in Stripe. It has its own signing secret, so paste the new secret into the card. Live-mode events count toward your server-track-lab usage like any live API event.
- Clear the Meta test event code when you're ready for production data.
FAQ
Which Stripe events should I send for SaaS conversions?
Two are required: checkout.session.completed (the moment a customer pays or starts a subscription through Checkout) and invoice.paid (every paid invoice, including renewals and subscriptions created without Checkout). Add checkout.session.async_payment_succeeded if you accept bank debits or other delayed payment methods; it uses the same key as the Checkout, so each payment counts once. Other event types are acknowledged with 200 and ignored.
Won't checkout.session.completed and the first invoice.paid count the same subscription twice?
Not with server-track-lab. Both are mapped to the same event_id, stripe_sub_ plus the subscription id, so whichever arrives first is forwarded as Subscribe and the other is deduplicated. Renewals (billing_reason subscription_cycle) are separate Purchase events keyed by the invoice id (stripe_in_ plus the invoice id), so two renewals both count and a duplicate delivery of the same invoice counts once.
How are amounts converted?
Stripe sends amounts in the currency's smallest unit. We divide by 100, except Stripe's zero-decimal currencies such as JPY and KRW, which are already whole units and stay as-is. ISK and UGX are sent by Stripe as two-decimal values, so they are divided by 100.
What happens if the signature is wrong?
We return 400 invalid_signature and store nothing. We check HMAC-SHA256 over the timestamp and raw body with your signing secret, accept any of the v1 signatures in the header, compare in constant time and reject timestamps older than 5 minutes.
Does the sample or a Stripe sandbox event count toward my quota?
No. Events with livemode false, including the dashboard's Send sample, are recorded as test events and don't count. Live-mode Stripe events count like any live API event.
Reference: Stripe source API docs · OpenAPI spec · Get a free API key
Sources (fetched Oct 18, 2026): Stripe docs: Receive Stripe events in your webhook endpoint (signatures, 5-minute default tolerance, retries for up to three days, duplicate events, quick 2xx), Supported currencies (zero-decimal list, ISK/UGX special cases). Stripe is a trademark of Stripe, Inc.; server-track-lab is not affiliated with Stripe.
Free tools: Meta & Google hashing tool · event_id generator · CAPI payload validator · Meta CAPI payload builder