-
Notifications
You must be signed in to change notification settings - Fork 0
feat(platform): add Meta Ads client (Graph API) #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 57 commits
Commits
Show all changes
61 commits
Select commit
Hold shift + click to select a range
ac8148c
feat(platform): add Meta Ads client (Graph API)
mrautela365 bdd7bf0
fix(review): add 429 rate-limit backoff to Meta client doRequest
mrautela365 00f6de1
fix(review): address Copilot findings on Meta client (PR #20)
mrautela365 a4ae5a7
fix(review): address second-round Copilot findings on Meta client (PR…
mrautela365 54d3021
fix(review): third-round Copilot findings on Meta client (PR #20)
mrautela365 a5b8e89
fix(review): round-4 Copilot findings on Meta client (PR #20)
mrautela365 d02b278
fix(review): round-5 Copilot findings on Meta client (PR #20)
mrautela365 cb6a533
fix(review): round-6 Copilot findings on Meta client (PR #20)
mrautela365 d8613b4
fix(review): ignore nil in WithHTTPClient (PR #20)
mrautela365 e6216df
fix(review): trim credentials at construction; don't fail throttled r…
mrautela365 24b1e99
fix(review): today-start ad set time; drop leads from field doc (PR #20)
mrautela365 2df2ba2
test(meta): pin the clock in date-based tests (PR #20)
mrautela365 d3d3f51
fix(review): exclude sanctioned geos, clock-pin remaining date tests,…
mrautela365 fdfcf31
fix(review): don't silently fall back to US; detect truncated respons…
mrautela365 7ae80b5
fix(review): exclude RU from Meta-eligible geos per PR #20 review
mrautela365 6e2fb0c
fix(review): treat ctx cancellation as fatal, fix geo guidance and SY…
mrautela365 d6e8ace
fix(review): validate host, surface orphan campaign id, fix test race…
mrautela365 446518c
fix(review): accurate ineligible-geo message and OKF log entry (PR #20)
mrautela365 92ad879
fix(review): distinguish caller-cancel from client HTTP timeout; hand…
mrautela365 d7e44aa
docs(meta): correct OKF concept doc for leads objective (PR #20)
mrautela365 730fbad
fix(review): use canonical tlf slug in campaign name fallback (PR #20)
mrautela365 7ec68e3
fix(review): honor currency offset for budget; reconcile leads mappin…
mrautela365 fdcd716
fix(review): surface orphaned creative id on non-fatal ad failure (PR…
mrautela365 02e0e6f
fix(review): account-currency budget semantics, validate copy limits,…
mrautela365 da6f3f6
fix(review): require explicit currency offset and non-empty project (…
mrautela365 228c972
fix(review): validate EventName up front; fix test-race on captured c…
mrautela365 48f119c
fix(review): default unset currency offset to 100 (PR #20)
mrautela365 51577cd
docs(test): correct CurrencyOffset test comment to match default-100 …
mrautela365 0c383c6
fix(review): O(1) placement dedup and note the leads mapping exceptio…
mrautela365 e58409b
fix(review): allocation-free truncate; guard unknown objective in bui…
mrautela365 5a7c99b
fix(review): use TrimPrefix to strip act_ in MetaURL (PR #20)
mrautela365 b569909
docs(okf): add recommended tags and timestamp frontmatter (PR: meta c…
mrautela365 cf24105
docs(okf): give the tags/timestamp change its own 2026-07-13 log head…
mrautela365 21b02fa
fix(review): reject removed Messenger Inbox placement (PR #20)
mrautela365 6a72ca6
fix(review): fail closed on unset Meta currency offset (PR #20)
mrautela365 7620e6b
fix(review): preflight-fetch Meta currency offset (PR #20)
mrautela365 0c12467
fix(review): guard offset overflow, correct offset docs (PR #20)
mrautela365 65fe6c5
fix(review): drop non-targetable ISO territories (PR #20)
mrautela365 95f3bed
fix(review): return partial result with created IDs on downstream fai…
mrautela365 766e47d
fix(review): derive currency offset from ISO code; clamp retry-after …
mrautela365 ccbdb72
fix(review): normalize EventName before generating names/UTM (PR #20)
mrautela365 6964463
fix(review): authoritative currency map + validate IDs (PR #20)
mrautela365 8d9383a
fix(review): reject inactive ad account; tighten currency docs (PR #20)
mrautela365 4be07e0
fix(review): expand supported-currency map; fix log ordering (PR #20)
mrautela365 0f99559
fix(review): normalize Objective (trim + lowercase) before use (PR #20)
mrautela365 ee04ad2
fix(review): IDR/HUF/COP/CRC/TWD are offset-1; reject conflicting off…
mrautela365 40b69f7
fix(review): report dropped ineligible/sanctioned geo targets (PR #20)
mrautela365 1dd939a
fix(review): abort on over-cap Retry-After; document authoritative of…
mrautela365 bd56863
fix(review): map leads to OUTCOME_TRAFFIC to avoid orphaning the camp…
mrautela365 5cbcf4f
fix(ci): compose test userinfo URLs at runtime to satisfy secretlint …
mrautela365 b86995f
fix(review): name dropped geos; preserve Graph envelope on rate-limit…
mrautela365 6e5c3df
fix(review): don't reject ANY_ACTIVE(201) accounts; reject malformed …
mrautela365 48ed506
fix(review): validate composed creative name length before any POST (…
mrautela365 0657619
fix(review): drop stray Date segment from Meta campaign name
mrautela365 f31a4a9
fix(review): normalize inputs in Meta validation and naming helpers
mrautela365 00388f2
fix(review): trim RegistrationURL in place so UTM build uses clean base
mrautela365 f3e2e7b
fix(review): restore reddit log entries, dedup audience_network, raw …
mrautela365 0de1bf6
fix(review): raw Retry-After as authoritative, harden buildUTMURL tri…
mrautela365 1caa8f9
fix(review): blank-only geos default to US, strict variant + date checks
mrautela365 36fe887
fix(review): ambiguous-outcome reconciliation, UTM secret stripping, …
mrautela365 5c8d874
fix(review): gate error-envelope unmarshal to non-2xx, move ISO table…
mrautela365 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| --- | ||
| type: "Go Package" | ||
| title: "internal/platform/meta" | ||
| description: "Meta (Facebook/Instagram) Ads Graph API client: Campaign -> Ad Set -> Ad creation with objective mapping and geo/budget validation." | ||
| resource: "internal/platform/meta" | ||
| tags: | ||
| - platform-client | ||
| - meta | ||
| - facebook-ads | ||
| - graph-api | ||
| - go-package | ||
| timestamp: "2026-07-13T19:22:00Z" | ||
| --- | ||
|
|
||
| # internal/platform/meta | ||
|
|
||
| Package meta provides a Go client for the Meta (Facebook/Instagram) Ads Graph | ||
| API, ported from the upstream TypeScript `meta-ads.service.ts` client. | ||
| Credentials and account configuration are injected via `NewClient`; the client | ||
| never reads the process environment and uses only the standard library. | ||
|
|
||
| Authentication is a Graph API Bearer access token. `CreateCampaign` drives the | ||
| Campaign -> Ad Set -> Ad(s) hierarchy, creating everything PAUSED, with | ||
| objective->parameter mapping (awareness/traffic/engagement/leads/conversions), | ||
| placement/promoted-object building, and UTM URL construction that preserves any | ||
| URL fragment. | ||
|
|
||
| The `leads` objective INTENTIONALLY DIVERGES from the `@lfx-one/shared` TS | ||
| contract (`campaign.constants.ts` maps leads -> LEAD_GENERATION with a page_id | ||
| promoted object). LEAD_GENERATION optimization requires the ad creative to carry | ||
| an on-Facebook instant lead form (`lead_gen_form_id`), which this client does not | ||
| construct — it only builds a website-click creative pointing at the registration | ||
| URL. Adopting LEAD_GENERATION would fail at ad-set/ad creation, after the paid | ||
| campaign already exists. To stay fail-safe, `leads` runs an interim WEBSITE-TRAFFIC | ||
| campaign — OUTCOME_TRAFFIC optimizing for LINK_CLICKS to the registration | ||
| (lead-capture) URL, with no promoted object. OUTCOME_TRAFFIC is used (not | ||
| OUTCOME_LEADS) because OUTCOME_LEADS + LINK_CLICKS requires a `pixel_id` + | ||
| `custom_event_type` that this interim flow does not supply — that pairing would | ||
| create the campaign then fail at the ad set, orphaning it. OUTCOME_TRAFFIC | ||
| supports LINK_CLICKS with no pixel requirement, so the flow is spendable | ||
| end-to-end. Full LEAD_GENERATION / instant-form (or OUTCOME_LEADS + pixel) parity | ||
| with the TS contract is deferred (LFXV2-2665). | ||
|
|
||
| Inputs are validated up front, before any mutating call: geo targets are checked | ||
| against ISO 3166-1 alpha-2 and comprehensively-sanctioned countries are | ||
| excluded; per-variant copy is rejected up front when it exceeds Meta's limits | ||
| (primary text 125, headline 40, description 30 characters, counted by rune) so | ||
| over-limit copy fails before any paid campaign/ad-set exists rather than at | ||
| non-fatal creative creation; `CampaignInput.Budget` is denominated in the ad | ||
| ACCOUNT's own currency — the client does NO foreign-exchange conversion, so the | ||
| caller must pass an amount already in that currency — and it is bounded | ||
| (rejecting rounds-to-zero and overflow-scale values) then converted to minor | ||
| units by multiplying by the account's minor-unit offset | ||
| (`AccountConfig.CurrencyOffset`) rather than a hardcoded ×100. That offset is | ||
| DERIVED from the account's ISO 4217 currency code, not fetched: the Meta AdAccount | ||
| node exposes only `currency` (the ISO code) — it does NOT expose a | ||
| `currency_offset` field (only the separate Currency node does). CreateCampaign | ||
| maps the code through an AUTHORITATIVE supported-currency table | ||
| (`currencyMinorUnitOffset`), which is the single source of truth: the zero-decimal | ||
| currencies (JPY, KRW, CLP, VND, and the rest of the standard set) map to 1, and the | ||
| enumerated two-decimal currencies (USD, EUR, GBP, and the other supported majors) | ||
| map to 100. There is NO fall-through default — a code absent from the table | ||
| (blank, or a well-formed-but-unknown code such as `ZZZ`) is treated as unsupported. | ||
| The offset is never guessed: when `AccountConfig.CurrencyOffset` is unset (zero) — the normal | ||
| case for a dispatch built from a persisted connection, which carries only | ||
| account/page/app IDs — CreateCampaign fetches the account's `currency` (ISO code) | ||
| from the ad-account object during the account preflight, BEFORE any mutating call, | ||
| derives the offset from it, and fails closed if the currency is unknown or absent. | ||
| Silently defaulting to 100 would encode a zero-decimal-currency | ||
| (JPY/KRW/CLP) budget 100× too high, and a warning after resource creation cannot | ||
| prevent that budget from being activated. The account currency is | ||
| authoritative: a caller MAY set a positive `CurrencyOffset` as a FALLBACK, but if | ||
| the preflight returns a recognized currency whose true offset differs, the request | ||
| is REJECTED (a stale override would mis-scale the budget). The explicit offset is | ||
| only used when the preflight fails or its currency isn't in the supported map. The | ||
| preflight GET always runs (it also verifies access). A negative offset is | ||
| rejected as malformed. The preflight also reads `account_status`: a | ||
| successful GET is not treated as "active" — if the account is in a known-inactive | ||
| state (disabled, closed, pending review/settlement, etc.) CreateCampaign fails | ||
| BEFORE any mutating call rather than creating a paid campaign Meta would reject | ||
| later; an unreported status (0) or any value not known to be bad is allowed | ||
| through. `CampaignInput.Project` is | ||
| also required (rejected up front if empty/whitespace): the campaign name's | ||
| Project segment must be the caller-supplied canonical LFX project slug, so the | ||
| client never silently substitutes a placeholder that could mis-attribute a | ||
| campaign to the wrong project. | ||
| Dates are parsed strictly (impossible calendar dates rejected) and a | ||
| past start date is refused, with a same-day ad-set `start_time` nudged to | ||
| now+buffer. `doRequest` retries HTTP 429 and Graph rate-limit envelope codes | ||
| (4/17/32/341/613/80004) with bounded backoff, draining the body before close, and a | ||
| truncated response body is surfaced rather than reported as a false success. | ||
|
|
||
| See [internal/platform/meta](../../../internal/platform/meta). |
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
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.