| name | meta-graph-api |
| description | Implement or maintain a full Meta integration through the Graph API on a React + TypeScript + Supabase stack — Facebook OAuth for ad accounts, pages and Instagram accounts, campaign/adset/ad sync with insights, a campaign management tree with inline editing, end-to-end campaign creation, custom and lookalike audiences, pixels with the Conversions API, and the Instagram block (publishing, AI auto-post, DM inbox, keyword automation). Use when working on Meta Ads, Facebook, Instagram, Graph API tokens and scopes, ad sync, pixel events, or when a token expired, a scope is missing or a webhook stopped firing. |
| license | MIT |
| compatibility | Requires React 18+ with TypeScript and a Supabase project (Postgres with RLS, Deno Edge Functions). Needs a Meta app in Business type with the Marketing API and Instagram products, plus App Review for the production scopes. Instagram features require a Professional account linked to a Facebook Page. |
| metadata | {"version":"1.0.0","provider":"meta-graph-api","graph-version":"v21.0","body-language":"en","reference-language":"pt-BR","source":"extracted from a production multi-tenant ad management platform"} |
Meta integration via Graph API
A complete Meta module — Facebook Ads plus Instagram — extracted from a
production ad-management platform: 17 migrations, 30 edge functions, 17 hooks,
71 components, plus a six-sprint plan with acceptance gates.
The plan does not replicate the origin system's defects: it lists 18
concrete corrections (unified API version, missing GRANTs, absent foreign
keys, anon policies leaking every tenant's rows, a DM webhook with no HMAC
validation, no message deduplication…) and the artifacts implement them.
Reference docs are in Brazilian Portuguese (references/), UI strings in
the components are pt-BR. Code identifiers are English.
0. Orient yourself before touching anything
ls supabase/functions | grep -E "meta-|facebook-|instagram-"
grep -rl "facebook_integrations" supabase/migrations | head
- Nothing → fresh implementation: use
meta-implement, which walks the
sprints with gates. The order is not negotiable — OAuth before sync, sync
before creation.
- Partially → find the last passed gate (§6) and resume there.
- Broken → use
meta-debug. Meta failures are almost always a token, a
scope, or an ID belonging to the wrong account.
- Just a question → the contract below usually answers it.
1. Architecture
FRONTEND (React + React Query)
Settings → Meta: connection card · ad accounts · pages · IG accounts · scopes
Client → Campaigns: campaign tree (campaign → adset → ad), inline edit, bulk
Client → Audiences · Pixels · Auto-posts · DM inbox
│ supabase-js (RLS) │ invoke()
▼ ▼
SUPABASE (Postgres)
facebook_integrations · facebook_ad_accounts · facebook_pages
instagram_accounts · campaigns · ad_sets · ads · sync_jobs
facebook_sync_schedules · _execution_logs
meta_audiences · meta_pixels · pixel_events_log
instagram_auto_post_configs · _posts · _schedules · knowledge
instagram_conversations · _messages · _flows · _triggers
│
▼
EDGE FUNCTIONS (Deno) — all Graph calls go through _shared/meta-graph.ts
meta-oauth · check-meta-scopes · fetch-facebook-pages
sync-meta-campaigns (async job) · meta-sync-scheduler
create-meta-campaign-full · create/update-meta-adset · -ad · manage-campaign
manage-meta-audiences · manage-meta-pixels · send-pixel-event
publish-instagram-post · instagram-post-scheduler · generate-instagram-auto-post
instagram-messaging-webhook (PUBLIC, HMAC) · instagram-automation-executor
│
▼
🌐 Graph API v21.0
2. Graph API contract — get this right first
Base: https://graph.facebook.com/v21.0, as a single constant in
_shared/meta-graph.ts. Never inline a version anywhere else — the origin
system had five versions living side by side (v18 → v22), which is correction #1.
Three token types, and using the wrong one is the most common failure:
| Token | Where it lives | Used for | Expiry |
|---|
| User token | facebook_integrations.access_token | The whole Ads pipeline | ~60 days (long-lived) |
| Page token | facebook_pages.page_access_token | FB/IG publishing, DMs | Does not expire |
| App token | {app_id}|{app_secret} | debug_token only | — |
The OAuth exchange returns a short-lived token. You must exchange it again
with grant_type=fb_exchange_token to get the 60-day one — the origin system
skipped this and simply assumed 60 days (correction #3).
Content-Type: application/x-www-form-urlencoded on Marketing API creation
POSTs; JSON elsewhere. This trips people up: a JSON body on /campaigns fails
with an unhelpful error.
Endpoints in use
| Method | Endpoint | Purpose |
|---|
| GET | /oauth/access_token | code → token, and short → long-lived |
| GET | /debug_token | Validate token, list granted scopes |
| GET | /me/adaccounts · /me/accounts · /me/businesses | Discovery (paginated) |
| GET | /{business}/owned_pages · /client_pages · /owned_instagram_accounts | Business Manager assets |
| GET | /{adAccount}/campaigns · /adsets · /ads | Sync, with nested insights{} |
| POST | /{adAccount}/campaigns · /adsets · /adcreatives · /ads | Creation |
| POST | /{entityId} | Update name, status, budget, targeting |
| POST | /{entityId}/copies | Duplicate |
| GET/POST/DELETE | /{adAccount}/customaudiences · /{audienceId} | Audiences |
| GET | /{adAccount}/adspixels · /{pixelId}/stats | Pixels |
| POST | /{pixelId}/events | Conversions API |
| GET | /search?type=adinterest | Interest search |
| POST | /{igUser}/media → /media_publish | Instagram publishing (two steps) |
| GET | /{igUser}/media · /{mediaId}/children | IG feed and carousels |
| POST | /me/messages | Instagram DM (Send API) |
Attribution window on sync — omit it and your numbers will not match Ads
Manager:
.action_report_time(conversion).action_attribution_windows(["7d_click","1d_view"])
Full request/response samples: assets/examples/graph-api-insights.md,
criacao-campanha-requests.md, instagram-publish-requests.md,
escopos-oauth.md.
3. Things about Meta that bite
- Instagram publishing is two calls, not one: create a media container
(
/{igUser}/media), then publish it (/media_publish). Carousels are N
containers plus one parent. A container expires in ~24h.
- An adset's ad account must match its campaign's. Mixing them produces
errors that name neither — the origin system needed a dedicated
check-adset-account-mismatches function to find these.
insights{} comes back empty, not as an error, when the token lacks
ads_read or the account has no delivery in the period. Empty ≠ broken.
- Scopes are granted per user, not per app.
debug_token tells you what was
actually granted; never assume the requested list.
- The DM webhook must validate
x-hub-signature-256 (HMAC with the app
secret). Without it, anyone can post fake messages into the inbox
(correction #9).
- The DM webhook redelivers. Deduplicate by message
mid with a UNIQUE
constraint, or a redelivery duplicates the message and re-fires the
automation (correction #15).
redirect_uri is compared string for string between the auth dialog and
the token exchange. One trailing slash apart and Meta returns "URL blocked".
4. Adapter points — what "any application" means
The artifacts came from a multi-tenant ad platform. Six integration points are
host-app specific; everything else is self-contained.
| Artifact expects | What it is | How to adapt |
|---|
company_id + get_user_company_ids(uuid) | Tenancy. A SECURITY DEFINER function used by every RLS policy | Your tenant column and membership function (org_id, workspace_id). Single-tenant: keep one fixed tenant row rather than dropping the column |
clients table | Each client points to 1 ad account + 1 FB page + 1 IG account | Your customer/project entity, or drop the level and link assets to the tenant |
CompanyContext → activeCompany.company_id | Active tenant in the browser | Your equivalent context |
useAuth, use-toast, cn() | Auth, toasts, class merge | Your equivalents |
VITE_FACEBOOK_APP_ID / FACEBOOK_APP_SECRET | Meta app credentials | App ID is a frontend env var; the secret is an Edge Function secret only — it must never reach the browser |
OAUTH_ALLOWED_ORIGINS / APP_URL | Redirect URI allowlist | Your domains. Never reflect an arbitrary origin — that is an open redirect |
The UI components assume shadcn/ui + Tailwind. Hooks and edge functions port
unchanged to any UI kit.
5. Conventions that keep it working
- One Graph API version, in
_shared/meta-graph.ts. Every call imports it.
- App Secret never leaves the backend. Not in
.env of the frontend, not
in a component, not in a log.
- RLS through the
SECURITY DEFINER helper only. A direct subquery on an
RLS-protected table inside a policy fails silently — empty array, no error.
GRANT ... TO authenticated on every new table. The origin system had
none, which is why data "disappeared" from the UI with no error (correction #2).
- No
TO anon policies. A public dashboard is served by an Edge Function
with the service role that resolves the token — not by opening the table
(correction #13).
verify_jwt = false only for Meta webhooks. Action endpoints stay
authenticated (correction #8).
- Foreign keys declared, with
ON DELETE CASCADE. ad_sets/ads in the
origin had loose UUIDs (correction #6), and company_id was nullable, so a
row written without a tenant vanished from the UI (correction #14).
- React Query for all server state — no
useState mirrors of remote data.
- Never log a token, not even truncated. Log the entity id and the error code.
6. Roadmap (six sprints, each with a gate)
| Sprint | Delivers | Gate |
|---|
| 0 Foundation | Connection + campaign schema, RLS, GRANTs, .env, config.toml | Migrations apply; SELECT returns empty, not a permission error |
| 1 OAuth | _shared/meta-graph.ts, oauth (with long-lived exchange), scope check, pages | Connect the account → ad accounts, pages and IG accounts appear |
| 2 Sync & tree | Async sync job with insights, scheduler, campaign tree UI | Numbers match Ads Manager for the same period and attribution window |
| 3 Create & edit | Full campaign creation, adset/ad create+update, builder wizard, editors | A campaign created from the app appears in Ads Manager |
| 4 Audiences & pixels | Custom/lookalike audiences, pixels, Conversions API, interest search | An event sent via CAPI shows in Events Manager |
| 5 Instagram | Publishing, AI auto-post, DM webhook + inbox, keyword automation | A post publishes; a DM arrives in the inbox and fires its automation |
Each sprint has one document per phase in references/, with tasks, artifacts
and its own gate. references/ORQUESTRADOR.md is the master plan and lists all
18 corrections.
Use meta-implement to execute it, meta-debug when it breaks.
7. Failure modes worth memorizing
| Symptom | Almost always | Fix |
|---|
Frontend gets [], no error | Missing GRANT or broken tenancy helper | Grant to authenticated; check the helper |
| "URL blocked" on OAuth | redirect_uri differs from the registered one | Match exactly, including trailing slash |
| Token works, then stops after weeks | Short-lived token was never exchanged | grant_type=fb_exchange_token |
insights empty for every campaign | Missing ads_read, or no delivery in the period | debug_token to see granted scopes |
| Numbers do not match Ads Manager | Attribution window not set | action_attribution_windows(["7d_click","1d_view"]) |
| Adset creation fails with a vague error | Adset's ad account ≠ campaign's | check-adset-account-mismatches |
| IG publish returns a container id and nothing appears | /media_publish was never called | Two-step publish |
| DM arrives twice and automation fires twice | Webhook redelivery, no dedup | UNIQUE on instagram_messages(external_id) |
| Webhook never fires | Not subscribed, or verify_jwt left true | Subscribe the app; set false for the webhook only |
| Inbox needs a manual refresh | Table not in the Realtime publication | ALTER PUBLICATION supabase_realtime ADD TABLE … + REPLICA IDENTITY FULL |
8. File map
SKILL.md this file
references/
ORQUESTRADOR.md master plan: scope, 18 corrections, contract, gates
sprint-0…5/ one document per phase — tasks, artifacts, gate
assets/
migrations/ 17 SQL files, numbered in apply order (+ INDEX.md)
edge-functions/ 30 Deno functions + _shared/meta-graph.ts + config.toml
frontend/hooks/ 17 React Query hooks
frontend/components/ 71 components (connection, tree, builder, audiences,
pixels, Instagram, DM inbox)
frontend/pages/ 8 pages
examples/ Graph payloads, insights contract, OAuth scopes,
IG publishing, webhook payloads, .env.example
Companion skills: meta-implement (builds it, sprint by sprint) and
meta-debug (isolates what broke).
9. Security and policy
- App Secret is backend-only. A leaked secret lets someone impersonate your
app against every user who authorized it.
- User tokens are credentials with money attached — they can spend ad budget.
Never log them, never return them to the browser, encrypt at rest if your
platform supports it.
- The DM webhook is a public, unauthenticated endpoint. HMAC validation is
not optional.
- Meta's Platform Terms apply to what you build. Ad data belongs to the
advertiser; do not aggregate it across tenants. Production scopes require App
Review, and automated messaging must respect Instagram's 24-hour messaging
window. Building bulk unsolicited DMs is how apps get banned.
- Pixel/CAPI payloads carry personal data. Emails and phones must be
SHA-256 hashed, lowercase and trimmed, before they reach Meta.