first
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
# Meta Marketing API
|
||||
|
||||
Proxy over the Meta Graph / Marketing API: reading the ad account structure
|
||||
(ad accounts, campaigns, ad sets, ads, creatives) and insights (spend,
|
||||
impressions, clicks, conversions).
|
||||
|
||||
**This phase is read-only.** Every endpoint issues a GET upstream; campaign
|
||||
management is phase 2 (see `overview.md`, "Deliberately not wired").
|
||||
|
||||
Base URL: `https://graph.facebook.com/{version}` — the version comes from
|
||||
`META_API_VERSION` (default `v25.0`) and can be overridden per request with
|
||||
`X-Meta-Api-Version`, so a Graph upgrade needs no deploy.
|
||||
|
||||
## Credentials
|
||||
|
||||
| Header | Required | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `X-Meta-Access-Token` | one of these | Access token → sent upstream as `Authorization: Bearer`. |
|
||||
| `Authorization` | one of these | Standard `Authorization: Bearer <token>` — equivalent alternative (the X- header wins if both are sent). |
|
||||
| `X-Meta-App-Secret` | no, recommended | App secret; the proxy derives `appsecret_proof` per request. |
|
||||
| `X-Meta-Api-Version` | no | Graph version override, e.g. `v25.0`. |
|
||||
|
||||
> Use a **System User** token from Business Manager. Unlike a user OAuth token
|
||||
> it does not stop working when someone leaves the company or changes their
|
||||
> password, and when generated without an expiry it needs no refreshing at all.
|
||||
|
||||
Required permissions for this read-only phase: `ads_read`,
|
||||
`business_management`.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Structure
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| GET | `/ads/me/adaccounts` | Ad accounts the token can access. |
|
||||
| GET | `/ads/me/businesses` | Business Manager accounts the token can access. |
|
||||
| GET | `/ads/businesses/{business_id}/adaccounts` | Accounts owned by a business (`owned=false` → accounts shared with it). |
|
||||
| GET | `/ads/accounts/{account_id}` | Ad account detail. |
|
||||
| GET | `/ads/accounts/{account_id}/campaigns` | Campaigns in an account. |
|
||||
| GET | `/ads/accounts/{account_id}/adsets` | Ad sets in an account. |
|
||||
| GET | `/ads/accounts/{account_id}/ads` | Ads in an account. |
|
||||
| GET | `/ads/accounts/{account_id}/adcreatives` | Creatives in an account. |
|
||||
| GET | `/ads/campaigns/{campaign_id}` | Campaign detail. |
|
||||
| GET | `/ads/campaigns/{campaign_id}/adsets` | Ad sets in a campaign. |
|
||||
| GET | `/ads/campaigns/{campaign_id}/ads` | Ads in a campaign. |
|
||||
| GET | `/ads/adsets/{adset_id}` | Ad set detail. |
|
||||
| GET | `/ads/adsets/{adset_id}/ads` | Ads in an ad set. |
|
||||
| GET | `/ads/ads/{ad_id}` | Ad detail. |
|
||||
| GET | `/ads/adcreatives/{creative_id}` | Creative detail. |
|
||||
|
||||
`account_id` is accepted with or without the `act_` prefix.
|
||||
|
||||
Every list endpoint takes `fields` (comma-separated Graph fields, with a useful
|
||||
default), `limit`, `after` (cursor) and `all_pages` (auto-paging, on by
|
||||
default — see [Pagination](#pagination)). The account-level campaign
|
||||
/ adset / ad endpoints also take `effective_status`, a JSON array such as
|
||||
`["ACTIVE","PAUSED"]`.
|
||||
|
||||
### Insights
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| GET | `/ads/insights/{object_id}` | Synchronous insights — small reports. |
|
||||
| POST | `/ads/insights/{object_id}/jobs` | Start an async report job → `report_run_id`. |
|
||||
| GET | `/ads/insights/jobs/{report_run_id}` | Job status (`async_status`, `async_percent_completion`). |
|
||||
| GET | `/ads/insights/jobs/{report_run_id}/results` | Read a finished job. |
|
||||
| POST | `/ads/insights/{object_id}/run` | Start, wait for completion, return the rows. |
|
||||
|
||||
`object_id` is an ad account (`act_123`), campaign, ad set or ad id.
|
||||
|
||||
Parameters (identical across all of them): `fields`, `level`, `date_preset`,
|
||||
`time_range`, `time_increment`, `breakdowns`, `action_breakdowns`, `filtering`,
|
||||
`sort`.
|
||||
|
||||
**Sync or async?** Meta computes large reports asynchronously and will reject or
|
||||
time out a sync call that is too big. Rule of thumb: one account or a handful of
|
||||
campaigns over a short period → `GET`. Long periods, several breakdowns, or a
|
||||
whole account at ad level → `/run`.
|
||||
|
||||
`/run` polls until the job finishes or the wait budget
|
||||
(`META_ASYNC_MAX_WAIT_SECONDS`, default 120 s, overridable per call with
|
||||
`max_wait_seconds`) runs out. On timeout the job is **not** cancelled — the
|
||||
response carries `completed: false` and the `report_run_id`, so you can keep
|
||||
polling `/ads/insights/jobs/{report_run_id}` instead of losing the work.
|
||||
|
||||
#### Fields and levels
|
||||
|
||||
The default `fields` list is deliberately level-agnostic:
|
||||
|
||||
```
|
||||
spend,impressions,clicks,ctr,cpc,cpm,reach,frequency,actions,action_values,date_start,date_stop
|
||||
```
|
||||
|
||||
Name/id fields are level-specific — asking for `ad_id` at campaign level is an
|
||||
upstream error. Add them together with `level`:
|
||||
|
||||
- `level=campaign` → `campaign_id,campaign_name,…`
|
||||
- `level=adset` → `adset_id,adset_name,campaign_name,…`
|
||||
- `level=ad` → `ad_id,ad_name,adset_name,campaign_name,…`
|
||||
|
||||
Conversions live in `actions` / `action_values` (arrays keyed by
|
||||
`action_type`), not in a top-level metric.
|
||||
|
||||
### Generic read passthrough
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| GET | `/graph/{graph_path}` | Any Graph API GET. |
|
||||
|
||||
For everything the typed endpoints do not cover (custom audiences, ad rules,
|
||||
Instagram accounts, Pages…) without needing a deploy. GET-only by construction,
|
||||
so the read-only stance holds here too. Give the path **without** the version
|
||||
prefix — the version comes from config or `X-Meta-Api-Version`. Unknown query
|
||||
params are forwarded verbatim.
|
||||
|
||||
## Pagination
|
||||
|
||||
The proxy **follows `paging.next` for you by default** (`all_pages=true`), so a
|
||||
list call returns the complete list:
|
||||
|
||||
```json
|
||||
{ "data": [ ... ], "pages_read": 3, "truncated": false }
|
||||
```
|
||||
|
||||
`truncated: true` means the `META_MAX_PAGES` cap (default 100) was hit; the
|
||||
response also carries the `next` URL and a warning is logged. Graph's own page
|
||||
size defaults to 25 — pass a bigger `limit` (up to 500 on most edges) to fetch
|
||||
more rows in fewer round trips.
|
||||
|
||||
Pass `all_pages=false` to get a single raw page with the upstream
|
||||
`paging.cursors` untouched and drive the cursor yourself.
|
||||
|
||||
## Rate limits
|
||||
|
||||
`X-App-Usage`, `X-Ad-Account-Usage` and `X-Business-Use-Case-Usage` from the
|
||||
upstream response are copied onto our response — use them to pace calls.
|
||||
|
||||
## Errors
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| 401 `missing_credentials` | No token, or a malformed `X-Meta-Api-Version`. |
|
||||
| 400 `upstream_error` | Meta rejected the request (bad field, bad id, missing permission). |
|
||||
| 502 / 504 | Meta unreachable, 5xx, or a request timeout. |
|
||||
|
||||
Upstream errors keep Meta's status and the whole error object in
|
||||
`upstream_body`, including `code`, `error_subcode` and `fbtrace_id` (quote it
|
||||
when opening a case with Meta). `detail` prefers Meta's human-readable
|
||||
`error_user_msg` when present.
|
||||
|
||||
Common ones:
|
||||
|
||||
- **code 100, "Invalid appsecret_proof"** — the app enforces app-secret proof;
|
||||
send `X-Meta-App-Secret`.
|
||||
- **code 190** — token expired or revoked (a user OAuth token after a password
|
||||
change; a System User token generated with an expiry).
|
||||
- **code 200** — the token lacks `ads_read` on that ad account.
|
||||
- **code 17 / 613** — rate limited; check the usage headers and back off.
|
||||
|
||||
## Kde získat údaje (návod pro klienta)
|
||||
|
||||
- **Access token (System User)**: [Business Manager](https://business.facebook.com/)
|
||||
→ *Nastavení firmy → Uživatelé → Systémoví uživatelé* → **Přidat** →
|
||||
**Přidat aktiva** (vyberte reklamní účty, oprávnění *Správa kampaní*) →
|
||||
**Vygenerovat nový token**. Bez zadané expirace je token trvalý.
|
||||
Oprávnění pro tuto fázi: `ads_read`, `business_management`.
|
||||
- **App secret**: [developers.facebook.com](https://developers.facebook.com/apps/)
|
||||
→ vaše aplikace → *Nastavení → Základní → App Secret*. Povinný, pokud má
|
||||
aplikace zapnuté *Require app secret proof for server API calls*.
|
||||
- **ID reklamního účtu**: Business Manager → *Nastavení firmy → Reklamní účty*,
|
||||
nebo vlevo nahoře v Ads Manageru. Prefix `act_` je volitelný.
|
||||
|
||||
## curl examples
|
||||
|
||||
Campaigns in an account:
|
||||
|
||||
```bash
|
||||
curl "https://services.csbot.cz/apps/meta/ads/accounts/123456789/campaigns?limit=200" \
|
||||
-H "X-Meta-Access-Token: EAAG..." \
|
||||
-H "X-Meta-App-Secret: <APP_SECRET>"
|
||||
```
|
||||
|
||||
Daily campaign insights for June, run asynchronously:
|
||||
|
||||
```bash
|
||||
curl -X POST "https://services.csbot.cz/apps/meta/ads/insights/act_123456789/run\
|
||||
?level=campaign\
|
||||
&fields=campaign_id,campaign_name,spend,impressions,clicks,ctr,actions\
|
||||
&time_range=%7B%22since%22%3A%222026-06-01%22%2C%22until%22%3A%222026-06-30%22%7D\
|
||||
&time_increment=1" \
|
||||
-H "X-Meta-Access-Token: EAAG..." \
|
||||
-H "X-Meta-App-Secret: <APP_SECRET>"
|
||||
```
|
||||
|
||||
Anything not covered by a typed endpoint:
|
||||
|
||||
```bash
|
||||
curl "https://services.csbot.cz/apps/meta/graph/act_123456789/customaudiences?fields=id,name,approximate_count" \
|
||||
-H "X-Meta-Access-Token: EAAG..."
|
||||
```
|
||||
@@ -0,0 +1,132 @@
|
||||
# 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:
|
||||
|
||||
```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.
|
||||
Reference in New Issue
Block a user