Skip to content

Carry signup attribution into the Stripe subscription - #322

Merged
paulocastellano merged 1 commit into
mainfrom
feat/stripe-checkout-attribution-metadata
Sep 1, 2026
Merged

Carry signup attribution into the Stripe subscription#322
paulocastellano merged 1 commit into
mainfrom
feat/stripe-checkout-attribution-metadata

Conversation

@paulocastellano

@paulocastellano paulocastellano commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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, goals and referral_source as metadata on the Checkout session.

Subscription metadata, not session metadata

Cashier's withMetadata() writes into subscription_data.metadata during 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.completed webhook, 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:

field why stored as
persona, referral_source enum-cast, so the attribute is a BackedEnum object ?->valueagency, product_hunt
goals JSON column, so the attribute is an array comma-joined — grow_audience,save_time
utm_* (5) plain strings as-is

Comma-joining goals over goals_0 / goals_1 or a JSON blob keeps it readable in the Stripe dashboard and filterable.

array_filter drops the keys the user never filled in: an absent key reads the same in reporting, and Stripe treats an explicit null as 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

StartSubscriptionCheckoutTest asserts the exact payload reaching withMetadata() — the full mapping in one case, and [] for an owner who filled in nothing.

Full suite green: 4062 passed.

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
paulocastellano force-pushed the feat/stripe-checkout-attribution-metadata branch from c8e41bf to d63a2a2 Compare September 1, 2026 01:27
@paulocastellano
paulocastellano merged commit c1af033 into main Sep 1, 2026
5 checks passed
@paulocastellano
paulocastellano deleted the feat/stripe-checkout-attribution-metadata branch September 1, 2026 01:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant