Carry signup attribution into the Stripe subscription - #322
Merged
Conversation
Every account arrives with the UTM parameters that brought it and the onboarding answers it gave, but none of that reached Stripe. Revenue lived in one system and the campaign that produced it in another, so answering "which campaign is paying for itself" meant joining the two by hand on email. The account owner's five UTM parameters, persona, goals and referral source now ride along as subscription metadata. Cashier's withMetadata() puts them in subscription_data.metadata during Checkout, so they land on the Stripe Subscription rather than the session: the session is gone in 24 hours, the subscription carries the attribution for as long as the customer does, and it shows up on the subscription in the dashboard. Stripe metadata holds strings, so the two enum-cast columns are unwrapped to their backing value and goals -- a JSON column, the one list answer -- is joined with commas. Passing either through as-is fails the request. array_filter drops the keys the user never filled in, since an absent key reads the same in reporting and Stripe treats null as a delete. The account may have no owner, so every read is null-safe and an account without attribution simply sends no metadata.
paulocastellano
force-pushed
the
feat/stripe-checkout-attribution-metadata
branch
from
September 1, 2026 01:27
c8e41bf to
d63a2a2
Compare
paulocastellano
deleted the
feat/stripe-checkout-attribution-metadata
branch
September 1, 2026 01:31
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Every account arrives with the UTM parameters that brought it and the onboarding answers it gave — persona, goals, referral source — and none of it reached Stripe. Revenue lived in one system and the campaign that produced it in another, so answering "which campaign is actually paying for itself" meant joining the two by hand on email.
This forwards the account owner's five UTMs plus
persona,goalsandreferral_sourceas metadata on the Checkout session.Subscription metadata, not session metadata
Cashier's
withMetadata()writes intosubscription_data.metadataduring Checkout, so the values land on the Stripe Subscription, not the session.That distinction matters: a Checkout session expires in 24 hours, while the subscription carries the attribution for as long as the customer does — visible on the subscription in the dashboard, and available to every report built on subscriptions. Attribution on the session would only ever be readable from the
checkout.session.completedwebhook, where we already know the account anyway.Shaping the values for Stripe
Stripe metadata holds string key/value pairs, so two columns cannot go through as-is — passing either fails the request outright:
persona,referral_sourceBackedEnumobject?->value—agency,product_huntgoalsgrow_audience,save_timeutm_*(5)Comma-joining
goalsovergoals_0/goals_1or a JSON blob keeps it readable in the Stripe dashboard and filterable.array_filterdrops the keys the user never filled in: an absent key reads the same in reporting, and Stripe treats an explicitnullas a delete instruction. Every read is null-safe, so an account with no owner simply sends no metadata.Scope
The ad click IDs sitting next to the UTMs on the users table (
gclid,fbclid,li_fat_id,ttclid,rdt_cid,epik) are deliberately left out. Worth adding if we ever want to import conversions back into Google or Meta Ads, but that is a separate decision from campaign reporting.Tests
StartSubscriptionCheckoutTestasserts the exact payload reachingwithMetadata()— the full mapping in one case, and[]for an owner who filled in nothing.Full suite green: 4062 passed.