Files
JiriUhlir a7cb53e0e6 first
2026-07-20 07:36:55 +02:00

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

  • 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 <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 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.