Skip to content

feat(#1197): measure the ad campaign, without storing attribution on a booking - #1198

Merged
TortoiseWolfe merged 5 commits into
mainfrom
feat/openai-ad-attribution
Sep 18, 2026
Merged

TortoiseWolfe merged 5 commits into
mainfrom
feat/openai-ad-attribution

Conversation

@TortoiseWolfe

Copy link
Copy Markdown
Owner

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.ts building its row by naming two fields, and in a
test. 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 orders or payment_intents, its recorded reasoning (spec.md:486) is about consent
rather than tables, and it names its own revisit condition:

Revisit when campaign volume makes the gap visible.

This takes that invitation. leads is untouched. Attribution lives on the purchase path and is
captured, 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

ad click ──► /?oppref=…       (trailing-slash 301 preserves query strings — verified live)
  ├─ no marketing consent ──► nothing captured, nothing reported
  ├─ pixel (consent-gated)  ──► lead_created on "Book a call"
  └─ /checkout ─► create-order ─► payment_intents.metadata.oppref
        └─► create-stripe-checkout ─► Stripe payment_intent_data.metadata.oppref
              └─► stripe-webhook, payment PROVEN ─► Conversions API order_created

No migration. payment_intents.metadata is already JSONB and create-order already builds a
metadata object.

Server-side for the sale, because a browser-only order_created is lost whenever the buyer closes
the tab on Stripe's page. It fires after the order advances and the receipt sends, and
ad-conversions.ts cannot throw — a rejection inside a Stripe handler is a 500, Stripe retries a
500 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.yml not passing it, .env.example not documenting it, the fork
inventory 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

  • 5876/5876 vitest · 989/989 test:scripts · deno check clean on create-order,
    create-stripe-checkout, 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 just a
    refusal.
  • The component test asserts it renders nothing on analytics-only consent, so accepting site
    measurement is never silently treated as accepting an ad network.

Not live until you set four values

NEXT_PUBLIC_OPENAI_PIXEL_ID   repo VARIABLE             Ads Manager → Conversions
OPENAI_ADS_PIXEL_ID           Supabase function secret  same value, for stripe-webhook
OPENAI_ADS_API_KEY            Supabase function secret  genuinely secret
NEXT_PUBLIC_GA_MEASUREMENT_ID repo VARIABLE             revives GA

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, reportAdConversion returns false.

🤖 Generated with Claude Code

TurtleWolfe and others added 5 commits September 18, 2026 14:43
…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>
@TortoiseWolfe
TortoiseWolfe merged commit c671d2b into main Sep 18, 2026
26 checks passed
@TortoiseWolfe
TortoiseWolfe deleted the feat/openai-ad-attribution branch September 18, 2026 16:09
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.

A paid ad campaign is running and nothing can tell whether it works

2 participants