Repository navigation
feat(#1197): measure the ad campaign, without storing attribution on a booking - #1198
Merged
Merged
Conversation
…a booking A paid OpenAI Ads campaign is running against scripthammer.com and nothing could tell whether it produced anything. GA was dead (loader ships, no G- id in the bundle, variable unset), nothing read any inbound click id, and the campaign's Tracking parameters field was empty, so no identifier reached the site at all. FR-024a READ CORRECTLY. The obvious implementation is forbidden — but its literal subject is "a booking confirmation received from the scheduler", not orders or payment_intents, and its recorded reasoning (spec.md:486) is about CONSENT rather than tables. It also names its own revisit condition: "Revisit when campaign volume makes the gap visible." This takes that invitation. `leads` is untouched and create-lead still names its two fields; attribution lives on the PURCHASE path and is captured, stored and reported only with marketing consent. The revisit is recorded in the spec rather than worked around quietly. NO MIGRATION. payment_intents.metadata is already JSONB and create-order already builds a metadata object, so oppref is one more field in a path that exists end to end. Server-side for the sale, because a browser-only order_created is lost whenever the buyer closes the tab on Stripe's page rather than returning. It fires AFTER the order advances and the receipt is sent, and ad-conversions.ts cannot throw: a rejection inside a Stripe handler is a 500, and Stripe retries a 500 for three days — which is how the webhook was lost for a month (#1180). Ad measurement is not permitted to cost the money path. FOUR GUARDS CAUGHT INCOMPLETE WIRING, each a different way of shipping a variable that does nothing: deploy.yml not passing it, .env.example not documenting it, the fork inventory having no consequence recorded, and a committed talk slide listing every value. The inventory table is GENERATED — I hand-edited the markdown first and had to revert it. Verified: 5876/5876 vitest, 989/989 test:scripts, deno check clean on create-order, create-stripe-checkout and stripe-webhook. Both consent mutations bite — making captureOppref ignore the flag reddens 2 tests, making readOppref ignore it reddens 1, so a withdrawal provably still takes effect. NOT YET LIVE. Needs NEXT_PUBLIC_OPENAI_PIXEL_ID, OPENAI_ADS_PIXEL_ID and OPENAI_ADS_API_KEY from Ads Manager, and the campaign's Tracking parameters set. Unconfigured is a clean no-op. Closes #1197 Refs #629 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…iser key
OpenAI issues two credentials and they are not interchangeable. The conversion-setup API
reference makes the split explicit:
OPENAI_ADS_API_KEY the ad-account ADVERTISER key. Calls api.ads.openai.com for
management (POST /conversions/pixels, /conversions/api_keys,
/conversions/event_settings) and for reporting (spend,
conversions, order_created_roas).
OPENAI_CONVERSIONS_API_KEY returned BY POST /conversions/api_keys. The only key permitted
to send events to bzr.openai.com.
ad-conversions.ts was sending with the Advertiser key, which would have failed auth — silently,
because that module swallows every failure on purpose so a broken ad network cannot 500 a
Stripe webhook and trigger the three-day retry storm that disables an endpoint (#1180). The
silence is correct and it is also why this would not have announced itself: conversions would
simply never have appeared, and the obvious conclusion would have been "the pixel is wrong".
Also records the real Ads Manager URL shape in .env.example. The sections are NOT nested —
/billing works, /billing/overview 404s — and the domain 403s every automated request, so these
cannot be looked up and have to come from someone signed in. Writing it down beats rediscovering
it; I guessed the nested form and handed over a 404.
Verified: 989/989 test:scripts, deno check clean on stripe-webhook.
Refs #1197, #629
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… sample The previous commit claimed Ads Manager sections are "NOT nested" on the evidence of /billing working and /billing/overview 404ing. The conversions page is https://ads.openai.com/settings/ conversions — nested — so the rule was wrong the moment it met a second sample. Both real URLs are now recorded and the generalisation is replaced with the fact that matters: the paths are not derivable from each other, so do not infer one from the other. That inference is what produced a 404 handed to the owner as a link. Refs #1197 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ssed Three ads.openai.com paths were handed over this session built from a pattern (/billing/overview, /settings/general) and every one 404'd. The domain 403s every automated request, so none of them could be tested before being sent. developers.openai.com/ads/api-overview hyperlinks the real one in plain text: "You can create your Advertiser API key within the Settings page in Ads Manager" -> https://ads.openai.com/settings. No /general segment. Also records what the two keys can each do, because it changes the order of work: the Advertiser key provisions pixels, send-keys and event settings over the API and is the only one that reads spend, while the Conversions tab hands out a pixel id and a send-key with no Advertiser key at all. Getting the Advertiser key first replaces the manual clicking, which is the sequencing error made here. Refs #1197 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…and-POSTed OPENAI_ADS_PIXEL_ID was set by a hand-rolled POST to the Management API because EDGE_SECRET_KEYS did not list it. A secret this script cannot see is one it cannot rotate and a restore cannot replay -- the same gap that left the OAuth and Turnstile secrets nowhere at all when the Supabase project was deleted (#567), which is the reason readDesiredSecrets was moved onto .env in the first place. Listing all three makes the set recoverable and proved itself immediately: the dry run classified the hand-set pixel id as UNCHANGED, which is a digest confirmation that the earlier manual write landed correctly and that .env and the vault now agree. The pixel id is public -- it ships in the browser bundle -- but the functions need their own copy, so it belongs with the two keys rather than apart. Refs #1197 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8 tasks
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.
Closes #1197
A paid OpenAI Ads campaign is running against scripthammer.com with $500 in credits, and nothing
could tell whether it worked. GA was dead (loader ships, no
G-id in the bundle, variable unset),no code read any inbound click id, and the campaign's Tracking parameters field was empty — so a
click from ChatGPT was indistinguishable from a bookmark.
FR-024a, read correctly
The obvious implementation is forbidden: FR-024a bans storing marketing attribution, enforced in
the migration comment, in
create-lead/resolve.tsbuilding its row by naming two fields, and in atest. I initially read that as ruling out server-side reporting entirely — and that was wrong.
Its literal subject is "a booking confirmation received from the scheduler." It says nothing
about
ordersorpayment_intents, its recorded reasoning (spec.md:486) is about consentrather than tables, and it names its own revisit condition:
This takes that invitation.
leadsis untouched. Attribution lives on the purchase path and iscaptured, stored and reported only with marketing consent — honouring the reasoning, not merely
the letter. The revisit is written into the spec rather than worked around quietly.
Design
No migration.
payment_intents.metadatais already JSONB andcreate-orderalready builds ametadata object.
Server-side for the sale, because a browser-only
order_createdis lost whenever the buyer closesthe tab on Stripe's page. It fires after the order advances and the receipt sends, and
ad-conversions.tscannot throw — a rejection inside a Stripe handler is a 500, Stripe retries a500 for three days, and that is how the webhook was lost for a month (#1180). Ad measurement is not
permitted to cost the money path.
Four guards caught incomplete wiring
Each a different way to ship a variable that does nothing, and worth naming because they are the
reason this is complete:
deploy.ymlnot passing it,.env.examplenot documenting it, the forkinventory having no consequence recorded, and a committed talk slide that lists every value. The
inventory table is generated — I hand-edited the markdown first and had to revert.
Verification
test:scripts·deno checkclean oncreate-order,create-stripe-checkout,stripe-webhook.captureOpprefignore the flag reddens 2 tests, makingreadOpprefignore it reddens 1 — so a withdrawal provably still takes effect, not just arefusal.
measurement is never silently treated as accepting an ad network.
Not live until you set four values
Plus the campaign's Tracking parameters:
oppref={oppref}&campaign_id={campaign_id}&ad_id={ad_id}.Unconfigured is a clean no-op — the pixel renders
null,reportAdConversionreturns false.🤖 Generated with Claude Code