# 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` - `analytics` is 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. - `analytics` is 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_VERSION` sets the default and `X-Meta-Api-Version` overrides 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 ` | 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: ```json { "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 `PAUSED` server-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) - `/health` returns 200. - `/docs` loads; `/openapi.json` `servers` contains `/apps/meta`. - New endpoints appear in Swagger with their `X-` headers in "Try it out". - Secrets never appear in logs or source.