| name | meta-debug |
| description | Diagnose a broken Meta Graph API integration by isolating which link failed — OAuth returning "URL blocked", a token that expired or was never exchanged for a long-lived one, missing scopes, campaign sync returning nothing, insights coming back empty, numbers that disagree with Ads Manager, ad creation rejected with a vague error, Instagram posts that never publish, or DM webhooks that never fire or fire twice. Use when the Meta integration worked and stopped, when it never worked, or before changing code to "try something". |
| license | MIT |
| compatibility | Any project running the Meta Graph API module on Supabase. Read-only diagnosis by default; needs Supabase CLI or dashboard access to read function logs, and access to the Meta app dashboard for scopes and webhook subscriptions. |
| metadata | {"version":"1.0.0","part-of":"meta-graph-api"} |
Diagnose a Meta Graph API integration
Meta errors are famously unhelpful — they frequently name neither the field nor
the entity at fault. The cure is to isolate the link before reading code.
Almost every failure is one of four things: the wrong token, a missing scope,
an id from the wrong account, or a version/format mismatch.
Reference: ../meta-graph-api/references/, examples in
../meta-graph-api/assets/examples/.
The chain
[1] Meta app App ID/Secret, products added, redirect URI registered, scopes reviewed
[2] OAuth code → short-lived token → LONG-LIVED token → stored with expires_at
[3] Assets ad accounts / pages / IG accounts discovered and linked to a client
[4] Graph calls right token type, right version, right account for the entity
[5] Database write sync job writes campaigns/adsets/ads under the right tenant
[6] Frontend RLS + GRANT let this user read the rows
[7] Webhooks subscribed, verify_jwt=false, HMAC valid, deduped
Step 1 — the two commands that answer most questions
Always start here. debug_token is the single most useful call in the whole API.
curl -s "https://graph.facebook.com/v21.0/debug_token\
?input_token=$USER_TOKEN&access_token=$APP_ID|$APP_SECRET" | jq '.data'
Read three fields: is_valid, expires_at, and scopes. Requested ≠ granted —
a user can approve a subset, and the app then fails on exactly the missing one.
curl -s "https://graph.facebook.com/v21.0/me/adaccounts\
?fields=id,name,account_status&access_token=$USER_TOKEN" | jq '.data[]'
If the account you need is not in that list, no amount of code will find it —
it is a Business Manager permission problem, not a bug.
Step 2 — by symptom
"URL blocked" / OAuth fails immediately
Link [1]/[2]. The redirect_uri is compared string for string between the
auth dialog and the token exchange, and both must match a URI registered in the
app.
- One trailing slash apart → blocked.
http vs https → blocked.
- Registered
https://app.example.com/auth/callback, sent
https://app.example.com/auth/callback/ → blocked.
- Works in production, fails on localhost → localhost is not in the allowlist.
"The token worked for weeks and stopped"
Link [2]. The OAuth exchange returns a short-lived token (~1–2h). If nobody
ran the second exchange, what got stored expires far sooner than the 60 days the
code assumed.
select id, expires_at, expires_at < now() as expirado from facebook_integrations;
Fix: grant_type=fb_exchange_token. Then re-connect the account — an expired
token cannot be renewed, only replaced.
"Sync returns nothing" / "insights are all zero"
Link [4]. In order:
debug_token → is ads_read in scopes? Empty insights with no error is
the signature of a missing scope.
- Did the account actually deliver in that period? Zero is a legitimate answer.
- Read the function logs:
supabase functions logs sync-meta-campaigns --limit 50
Meta errors arrive as { error: { message, type, code, error_subcode } } —
code and error_subcode are what to search for, not the message.
"The numbers don't match Ads Manager"
Not a bug in 90% of cases: it is the attribution window. Ads Manager
defaults to 7-day click / 1-day view; a query without
.action_report_time(conversion).action_attribution_windows(["7d_click","1d_view"])
reports different numbers and is not wrong, just different. Also confirm both
sides use the same time zone (the ad account's, not the user's) and the same
date range boundaries.
Contract: ../meta-graph-api/assets/examples/graph-api-insights.md.
"Creating an adset/ad fails with a vague error"
Link [4]. Three usual causes:
- Account mismatch — the adset's ad account differs from its campaign's.
Meta's error names neither. The module ships
check-adset-account-mismatches for exactly this.
- Content-Type — Marketing API creation POSTs need
application/x-www-form-urlencoded. A JSON body fails obscurely.
- Nested objects must be JSON strings inside the form body (
targeting,
promoted_object), not nested form fields.
Compare your payload against
../meta-graph-api/assets/examples/criacao-campanha-requests.md.
"The Instagram post never appears"
Link [4]. Publishing is two calls:
POST /{igUser}/media → returns a container id
POST /{igUser}/media_publish → actually publishes it
Getting a container id back and stopping there is the most common bug. Also:
containers expire in ~24h; the IG account must be Professional and linked to
a Facebook Page; publishing uses the page token, not the user token.
"DM webhook never fires"
Link [7], in order:
supabase functions logs instagram-messaging-webhook --limit 50
- No entries at all → the app is not subscribed to the
messages field for
that page, or the callback URL failed Meta's verification handshake.
- 401/403 →
verify_jwt is still true. Meta webhooks must be false; the
security comes from HMAC, not from JWT.
- Entries rejected by signature → the HMAC uses the app secret over the
raw body. Parsing the JSON first and re-serializing it changes the bytes
and the signature never matches.
"Every DM arrives twice and the automation fires twice"
Meta redelivers. Deduplicate by message id:
select external_id, count(*) from instagram_messages
group by 1 having count(*) > 1;
Fix: UNIQUE (external_id) and an upsert. Without it, a redelivery duplicates
the message and re-fires the keyword automation — the user gets two replies.
"Everything returns an empty array, no error"
Link [6] — the signature failure of this stack:
select grantee, privilege_type from information_schema.role_table_grants
where table_name = 'campaigns';
select public.get_user_company_ids(auth.uid());
select policyname, qual from pg_policies where tablename = 'campaigns';
A policy that queries an RLS-protected table directly instead of going
through the SECURITY DEFINER helper fails silently. Also check company_id IS NULL rows: a write without a tenant vanishes from the UI with no error.
"The inbox needs a manual refresh"
The row was written; Realtime is not delivering.
select tablename from pg_publication_tables where pubname = 'supabase_realtime';
alter publication supabase_realtime add table instagram_messages;
alter table instagram_messages replica identity full;
Step 3 — report before fixing
Three lines: which link broke, the evidence (log line, debug_token
output, the exact Graph error code/error_subcode), and the fix. Ask
before anything that deploys, migrates, or touches a live ad entity.
If two links are still possible, say which check separates them.
Step 4 — fixing
- Compare the project's file with the packaged artifact in
../meta-graph-api/assets/ — the difference usually is the bug.
- Fix one link at a time and re-run that link's check.
- Never paste a token, App Secret, ad account id or message content into a
report.
- Never flip a live campaign's status while debugging. It spends real money.
Things that are not bugs
- Zero insights for a paused campaign with no delivery in the period.
account_status other than 1 — the ad account is disabled or unsettled on
Meta's side; the integration is fine.
- Scopes missing in Development mode — production scopes require App Review.
- A page you can see in Business Manager but not via
/me/accounts — page
access is granted per user; check the Business Manager role.
expires_at around 60 days out — that is a correctly exchanged
long-lived token, not a bug.