6.3 KiB
meta — overview
A stateless multi-tenant API proxy over the Meta Marketing API
(Facebook / Instagram advertising), exposed as one FastAPI app at
/apps/meta.
The structure deliberately mirrors the sibling analytics service
(config→env, credentials→headers, client per upstream, routers, central
exception handling, Swagger at /docs), so anyone who knows one knows the
other.
Why a separate app and not a module in analytics
analyticsis built around one shared Google OAuth mechanism (token minting from service-account keys, scopes, quota project). Meta shares none of it.- The Graph API needs a different client: form-encoded writes,
appsecret_proof, cursor pagination, async report jobs. - This module serves both Agents and csbot, so it is versioned and deployed independently.
analyticsis declared read-only. Campaign management (phase 2) lands here, where the write guard rails can be designed from the start.
Design principles
- Stateless / no stored secrets. Credentials arrive per request in
X-headers and are used only to call the upstream. Nothing is persisted; there is no token cache, because the caller always supplies a fresh token. - Thin passthrough. Graph request/response bodies are forwarded as-is, so callers keep the full upstream API surface. Only authentication, versioned URL building, pagination and error mapping are added.
- No silent failures. Every error is logged (never the secret values) and surfaced as JSON. Upstream errors preserve the upstream status and body.
- Version is config, not code.
META_API_VERSIONsets the default andX-Meta-Api-Versionoverrides it per request, so a Graph upgrade never needs a deploy here.
Layout
app/
config.py env-driven config (base URL, version, caps, timeout) — no secrets
logging_config.py get_logger(); secrets are never logged
errors.py MissingCredentialsError, UpstreamError + handlers
credentials.py X- header dependencies + appsecret_proof derivation
clients/
graph.py Graph client: auth, URL building, paging, error mapping,
rate-limit header capture
routers/
infra.py /health, /version
entities.py /ads/... ad accounts, campaigns, ad sets, ads, creatives
insights.py /ads/insights/... sync + async reporting
passthrough.py /graph/{path} generic read-only Graph GET
main.py app, root_path, usage middleware, router + handler registration
Reverse proxy
ROOT_PATH (/apps/meta) is passed to FastAPI's root_path, so the OpenAPI
servers entry and Swagger "Try it out" use the public prefix. Internal routes
are unprefixed (Caddy handle_path strips the prefix).
Authentication summary
| Header | Required | Behaviour |
|---|---|---|
X-Meta-Access-Token |
one of these | Sent upstream as Authorization: Bearer. A Business Manager System User token is recommended. |
Authorization: Bearer <token> |
one of these | Equivalent alternative; X-Meta-Access-Token wins if both are present. |
X-Meta-App-Secret |
no, recommended | The proxy derives appsecret_proof (HMAC-SHA256 of the token) per request. Required when the app enforces app-secret proof. |
X-Meta-Api-Version |
no | Per-request Graph version, e.g. v25.0. Validated as vNN.N, optionally restricted by META_ALLOWED_API_VERSIONS. |
The token goes upstream as a header, never as an access_token query
param, so tokens do not end up in intermediate access logs.
Configuration (environment variables)
| Variable | Default | Meaning |
|---|---|---|
META_GRAPH_BASE_URL |
https://graph.facebook.com |
Graph base URL. |
META_API_VERSION |
v25.0 |
Default Graph version. |
META_ALLOWED_API_VERSIONS |
(empty) | Optional comma-separated allowlist for X-Meta-Api-Version. Empty = any well-formed version. |
META_MAX_PAGES |
100 |
Page cap for auto-paging. |
META_ASYNC_POLL_INTERVAL_SECONDS |
2 |
Poll interval for async insights jobs. |
META_ASYNC_MAX_WAIT_SECONDS |
120 |
Wait budget for /run. |
HTTP_TIMEOUT_SECONDS |
60 |
Upstream request timeout. |
LOG_LEVEL |
INFO |
Log level. |
Rate limits
Meta reports quota consumption in X-App-Usage, X-Ad-Account-Usage and
X-Business-Use-Case-Usage. A middleware copies whatever the upstream returned
onto our own response, so callers can pace themselves instead of discovering a
throttle by being blocked.
Pagination
List endpoints follow paging.next by default (all_pages=true) and return
the complete list, so callers never have to implement a cursor loop:
{ "data": [...], "pages_read": 3, "truncated": false }
truncated: true (plus a next URL and a logged warning) means the
META_MAX_PAGES cap was hit — a partial result never masquerades as a complete
one. Graph's own page size defaults to 25, so pass a larger limit to cover
more rows in fewer round trips.
Pass all_pages=false for a single raw page with the upstream paging.cursors
untouched, when you want to drive the cursor yourself.
Deliberately not wired
- All write operations (phase 2). Creating or updating campaigns, ad sets
and ads is intentionally absent. Every current endpoint issues a GET, so the
service cannot spend money. Before writes are added, the agreed pattern is:
status forced to
PAUSEDserver-side (activation stays manual), an idempotency key so a retried request cannot create a duplicate campaign, and a budget ceiling enforced here as a backstop. - Instagram organic / Pages content. Reachable today through the read-only
/graph/{path}passthrough; typed endpoints can follow if they get regular use. - Header-credential encryption. Same deferral as
analytics/idoklad/csob: header values are plaintext over TLS for now. - Automated tests. There is no test suite yet (matching
analytics). Verification is the manual checklist below. This is acceptable while the service is read-only; it should not stay that way once writes land.
Verification checklist (per AGENTS.md)
/healthreturns 200./docsloads;/openapi.jsonserverscontains/apps/meta.- New endpoints appear in Swagger with their
X-headers in "Try it out". - Secrets never appear in logs or source.