Binding accounts via the OAuth broker (ingest) and reading data via Public Access (consume), addressed per end-user by
external_ref. Version 1.12 · 2026-08-31 (asal v1.0 2026-07-15) · Status: as-built (IN-6external_ref, shipped). v1.12 (konsolidasi patch service 2026-08-28/29): (1) §C2 baru — penamaan kunci response: kunci didata[]=titlekatalog, bukancode(418 dari 575 title nol-derivable) + tabel gaya kunci per-endpoint (Title Case vs snake_case; familigmvterbelah). (2) §C2 baru — dimensigroup_bygadwords:+campaign, grain vs non-grain (ad_shown), tiga dimensi yang dulu balik 1 baris. (3) §C4b baru — product/SKUGET /{platform}/catalog/{product-catalog|product-performance}(tiktok_shop·shopeeERP). (4) §7.1:ad(5→6),gadwords+campaign(9→10); per-ad rows + product/SKU pindah ke “Now available” — demografi/geo, creative/preview, Meta CPAS product-level rows tetap belum tersedia. (5) §C3:available= “reachable dari public API” dan definitif buat 9 platform;sourcenol laginull;livecuma dari 5 platform insights. (6) §8.1: 2 baris dibuang ·show_datedipecah (gadwords →422,ganalyticsmasih diabaikan diam-diam) · 15 baris baru (batas versi pra-2026-08-28, jebakanchannel_typegadwords, behaviour-changecampaign=true→422, famili catalog). (7) Koreksi:breakdown_interval=week|month|yearitu server-side — nol perlu agregasi sendiri (§C2 lama bilang “server insights hanya harian”). v1.6 (koreksi + discoverability): (1) §C3 DIPERBAIKI — field katalog metrik ditulis 10, aslinya 15;parent·child·source·available·caveatsebelumnya nol ke-dokumentasi padahal §1.1/§8 ngerujuk ke situ. Sekarang lengkap + tabel penjelasan tiap field + daftar 9 platform valid. (2) Snapshot statis katalog metrik di-publish dimetric-catalog/sebelah guide —/metricsbutuh Bearer key server-side, jadi agent/riset-dokumentasi secara struktural nol bisa manggil; snapshot nutup itu (live endpoint tetap otoritatif). v1.5: (1) §1.1 Scope baru — batas kontrak eksplisit: BDDAI = penyedia data; report/block shaping, period-comparison, alias-naming, dan roll-up = sisi integrator (by design, bukan backlog). (2) §7.1 baru — daftar dimensigroup_byLENGKAP per platform (jawaban pertanyaan berulang: facebook 5 · instagram/tiktok/shopee_ads 1 · gadwords 9). (3) §8.1 baru — known caveats:show_dateno-op di gadwords/ganalytics ·breakdown_interval=yearsilent-degrade ke harian di meta/ig/tiktok ·group_by=keywordsplural · gadwordsagecomposite-key. (4) Catatan Shopee: akun Open Platform restricted per 2026-08-27, RL-6 nol ETA. Regenerate.docx+ mirror demo setelah ini. v1.4: sinkron ke kontrakpublic-access.mdper 2026-08-20/21. (1)ad-accountsresponse nambahcurrency(SKS §5.3),store_id+combined_available(CMB-S4c) — additive, key lama nol berubah. (2) Endpoint/tiktok/combined(Ads×Shop, CMB-S4) di-dokumen (⚠️ flag-gated preview, ON di staging 2026-08-21, prod belum) — fieldads_attribution_coverageun-clamped + coverage-guard, BUKAN “kontribusi iklan”;ad_contribution_pct/ad_attribution_ratiolama DIHAPUS. (3) GMV Max nambah report Kelas Bcreatives/product-durations(data pending, 200-empty). Regenerate.docxsetelah ini. v1.3: Shopee Ads ingest sekarang validated post re-bind (CPC + ads_balance OK);campaign/campaign_hourlymasih unverified karena sandbox nol ada campaign aktif — bukan bug, bukan diklaim live. Consume/production-bind Shopee/Shopee Ads tetap gated (Fase-3.2/RL-6), nol diubah. v1.2: refresh Shopee/Shopee Ads status — bind live in staging sandbox (ERP ingest validated e2e; Ads bind reaches provider, ingest pending a re-bind fix), consume tetap gated (Fase-3.2/RL-6), production bind tetap gated too. Sebelumnya §6/§7/§8/§10 masih nulis Shopee flat “Gated 503” tanpa bedain bind vs consume — sekarang dipisah eksplisit. v1.1: menambah §“Environments — Base URLs” (direct endpoint per-env: staging*.stg.bdd.ai/ prod*.bdd.ai). Domain Railway*.up.railway.app= DEPRECATED, diganti custom domain. A formatted.docxof this guide is available alongside this file (regenerate setelah update ini).
BDDAI lets an external application (a source_app) connect its end-users’ ad / e-commerce accounts and read their data — without ever handling OAuth tokens. Your app holds one public-access key; you may serve many end-users under it.
external_ref); BDDAI maps it to an internal user id. See §4.BDDAI Public Access is a data provider. It returns the underlying numbers — summary, daily series, and breakdown/table rows — together with the parameters to select them. It is not a reporting or presentation service. Understanding this line up front saves integration time, because several things that look like gaps are deliberate design.
Out of scope — by design, not a backlog item:
compare parameter — intentionally. To show current-vs-previous or a delta, call the same endpoint twice with identical parameters except date_start/date_end, then compute the difference yourself. > BDDAI’s own dashboards do offer comparison, but that is computed by BDDAI’s internal backend and is not part of this contract. Don’t treat internal product behaviour as an expectation for Public Access. > ⚠️ If you render deltas for ratio metrics (CTR, conversion rate, ROAS, any *_rate): a percent-change of a ratio is misleading. A CTR moving 5.33% → 4.78% renders as −10.35% when the actual movement is −0.55 percentage points. Use point deltas for ratios and percent deltas for absolute/currency values.title as the canonical name. Mapping it to your own display vocabulary — including reconciling aliases inside your own templates — is yours.breakdown_interval natively (§C2).Concrete example of the boundary. Our internal dashboard’s product-performance surface does support a comparison window. The public API’s equivalent (
/{platform}/catalog/product-performance) deliberately does not — andsort_by=growth, which needs one, returns422. That difference is the boundary in §1.1 doing its job, not an oversight: comparison is shaped per-product-decision, so it belongs to the integrator that owns the report.
In scope — ours: that the data exists, is fetchable, is correctly dimensioned, and is accurate. Endpoints, parameters, dimensions, the metric catalog, and the correctness of the numbers.
How to raise a gap (this materially speeds things up): phrase it as “metric X at dimension Y for platform Z is not retrievable”, not as “block W isn’t supported”. The first is actionable immediately; the second has to be translated first, and usually resolves to composition work on your side rather than anything BDDAI needs to build. Before filing, check §C3 /metrics — the available, source, and caveat fields answer most availability questions definitively.
Ask a BDDAI administrator to provision (once):
source_app — you receive a code (e.g. partnerx).return_url makes /oauth/start return 403 for every request.purpose='ingest') scoped to the platforms you may bind (allowed_platforms). The plaintext key is shown once, at issue time.Keep the key server-side — in an environment variable. Never put it in a frontend bundle, a log line, or any browser-visible response.
Every request authenticates with the same key:
Authorization: Bearer pk_live_…
The key is hashed (HMAC-SHA256) and looked up server-side. Guard order: missing/invalid scheme → 401; unknown/inactive key → 401; expired → 401; IP not allowed (if the key is IP-locked) → 403; platform not in allowed_platforms → 403; source_app suspended → 403.
1-key model: a single ingest key is used for both binding and consuming in this reference setup. Whether real integrators get one key or two (least-privilege) is an administrator decision — confirm with your BDDAI contact.
The {binding_base} / {service_base} placeholders used in §5–§6 resolve per environment. Use the HTTPS custom domains below. (Legacy *.up.railway.app staging URLs are DEPRECATED — replaced by *.stg.bdd.ai; *.devbdd.com is retired.)
| Placeholder | Service | Staging | Production |
|---|---|---|---|
{binding_base} |
Binding — OAuth broker (ingest / bind) | https://binding.stg.bdd.ai |
https://binding.bdd.ai |
{service_base} |
Service — Public Access (provision + consume) | https://service.stg.bdd.ai |
https://service.bdd.ai |
Direct endpoint = base + path. Contoh consume (prod): GET https://service.bdd.ai/api/v3/public-access/facebook/ad-accounts?external_ref=…. Contoh bind (staging): GET https://binding.stg.bdd.ai/api/v3/oauth/start?platform=….
Your app integrates directly with {binding_base} (bind) + {service_base} (provision + consume). The demo-middleware BFF is a reference harness only (shows the full loop, not an integration target): staging https://demo.broker.stg.bdd.ai · prod https://demo.broker.bdd.ai.
Full 5-service domain matrix (client-service / admin / service / binding / broker, staging+prod) =
ga-prod-readiness/00-Plan/DEPLOY-RUNBOOK.md §Domain per environment.
external_refYour app never sends a raw BDDAI user id. Instead you send external_ref — a stable identifier in your own namespace (e.g. your user’s primary key or email). BDDAI resolves (source_app, external_ref) → an internal user id and holds the mapping.
external_ref always maps to the same end-user (no token replacement).external_ref string under a different source_app is a different end-user — apps can never reach each other’s users.user_id is rejected: sending a numeric BDDAI user_id on the external path returns 400. Always use external_ref.You do not need to store any id BDDAI returns — keep using your own external_ref on every call.
Three phases: (A) provision the end-user, (B) bind a platform via the broker, (C) consume the account list and data. The token is exchanged and stored server-side; only ?status= is ever carried back to your app.
sequenceDiagram
participant App as External App (backend)
participant Br as End-user Browser
participant Bind as BDDAI Binding (broker)
participant Plat as Ad Platform
participant Svc as BDDAI Service (consume)
Note over App,Svc: A · PROVISION
App->>Svc: POST /api/v3/public-access/users { external_ref } + Bearer key
Svc-->>App: 200 { user_id } (idempotent)
Note over App,Svc: B · BIND (OAuth broker)
App->>Bind: GET /api/v3/oauth/start ?platform&source_app&return_url&external_ref + Bearer key
Bind-->>App: 302 Location: consent URL
App->>Br: redirect browser to consent URL
Br->>Plat: user consent / login
Plat->>Bind: callback ?code (token exchange server-side, store keyed user_id)
Bind-->>Br: 302 {return_url}?status=success (token NEVER to client)
Note over App,Svc: C · CONSUME (per end-user, by external_ref)
Br->>App: return page loads (?status=success)
App->>Svc: GET /{platform}/ad-accounts ?external_ref + Bearer key
Svc-->>App: 200 { data:[ accounts ] }
App->>Svc: GET /{platform}/data ?account_id&external_ref
Svc-->>App: 200 { data, pagination }
An end-user who already bound can refresh or re-authorize without any special endpoint:
GET /oauth/start again with the same external_ref (and platform). It resolves to the same end-user (idempotent — no new user, no duplicate account); the user re-consents; the stored token is updated in place (upsert on user+account). Same 302 → consent → return flow as first bind.Reuse the same external_ref, no re-provisioning, no separate “rebind” endpoint. First-time and repeat binds are the exact same call.
POST {service_base}/api/v3/public-access/users
Authorization: Bearer <key> # purpose='ingest'; source_app derived from key
Content-Type: application/json
{ "external_ref": "<your-user-ref>" } # required, 1..200 chars
→ 200 { "user_id": <int> } # idempotent
Errors: 401 invalid key · 403 purpose≠ingest · 422 external_ref empty/too long, or key not scoped to a source_app. (Binding also auto-provisions on first bind, so an explicit call here is convenient but not strictly required.)
GET {binding_base}/api/v3/oauth/start?platform=&source_app=&return_url=&external_ref=
Authorization: Bearer <key> # a browser redirect cannot attach this header → call from your backend
→ 302 Location: <platform consent URL> # do NOT follow server-side; redirect the browser to it
Errors: 401 auth · 400 unknown platform · 403 purpose/allowed_platforms/return_url-not-allowlisted/source suspended/cross-source ref · 404 source_app not found · 503 platform gated — production Shopee/Shopee Ads pending RL-6, or a fully-disabled platform (e.g. Shopify). Shopee/Shopee Ads bind itself is live in staging sandbox (not gated there).
{return_url}?status=success # or ?status=failed
The token is never in the redirect or any client-visible response. status=failed = replayed/expired session OR downstream token-exchange failure; treat both the same (retry from step B).
GET {service_base}/api/v3/public-access/{platform}/ad-accounts?external_ref=
Authorization: Bearer <key>
→ 200 { "data": [ { "account_id", "account_name", "description",
"currency", "store_id", "combined_available" } ] }
currency (ISO-4217, mis. IDR/USD/MYR) — selalu dikirim; null = tidak diketahui, BUKAN “pasti IDR”. ⚠️ Jangan default ke IDR waktu null (di data nyata ada ribuan akun USD + puluhan MYR/GBP/SGD/JPY/dll). Coverage nggak merata per platform (gadwords ~99.8%, tiktok ~97.3%, facebook ~78%, instagram/ganalytics selalu null by design — nol metrik uang) → siapkan fallback sadar.store_id + combined_available (tiktok, additive) — store_id = toko yang ke-map ke akun Ads ini (kunci korelasi Ads×Shop); combined_available = true cuma kalau akun tiktok DAN punya store_id. Platform lain → selalu false (bukan null). Pakai buat nyaring akun yang eligible /tiktok/combined (lihat C5) tanpa harus manggil lalu kena 404.Errors: 401 · 403 platform not allowed · 404 ref unknown · 422 ref empty / key unscoped · 429 rate-limited.
⚠️ The keys inside
data[]below are response keys, not metric codes — they come from our metric title table. See §C2 → Response key naming (further down this section) before mapping them, and note thatorders,ganalytics,catalog,combinedandgmv/overviewuse snake_case instead.
GET {service_base}/api/v3/public-access/{platform}/data
?account_id=&external_ref=&date_start=&date_end=&campaign=&objective=&show_date=&page=&page_size=
→ 200 { "data": …, "pagination": { page, page_size, total, total_pages } | null }
Consume path per platform: facebook · instagram · tiktok (insights-family) · gadwords (Google Ads) · ganalytics (GA4). SV-C6 udah landing — periode (day/week/month/year) + table/breakdown + filter tersedia; model beda per grup:
Insights-family (facebook / instagram / tiktok): - Summary — tanpa dimensi: data = 1 dict agregat, pagination: null. → KPI cards. - Series — show_date=true (harian) ATAU breakdown_interval=day|week|month|year (SV-C6): row per-periode. → line/bar over time. > Bucket label lands in _id, formatted per interval: day → 2026-03-15 · week → 2026-11 > (%Y-%U, Sunday-based, not ISO week) · month → 2026-03 · year → 2026. Parse by shape, > not by position — and do not assume week matches ISO-8601 week numbers. - Table / breakdown — campaign=true/objective=true ATAU group_by=objective: row per-dimensi (paginated). Ad-level group_by=device|platform|placement|campaign|ad = facebook SAJA (ig/tiktok → 422; device/platform/placement/campaign = data-gap, ad = belum dibuka — §7.1). - Filter — filter_by=campaign + filter=<id,..> (+ filter_exclude=true) → filter/exclude sampai level campaign.
Google Ads (gadwords): channel_type (search|display|shopping|video|…, v24, default search) + SV-C6: breakdown_interval=day|week|month (year → 422 — deliberate as of 2026-08-28, not pending work: the shared bucket helper does support year now, but the gadwords public-access contract was left unchanged. Ask if you need it.) + group_by=<10 dimensi GAds>. → periodic + table (Slice A; bukan summary-only lagi).
GA4 (ganalytics): report=overview|acquisition_channel|acquisition_source_medium|geo_country|geo_city|page_screen|device (default overview) + SV-C6: breakdown_interval=day|week|month|year → time-series metric (Slice C). overview→summary; report berdimensi→list paginated.
account_id + external_ref wajib; date default 30 hari terakhir, max span 92 hari; page_size ≤ 1000. Errors: 401 · 403 platform/account bukan milik end-user · 404 ref unknown · 422 range/param invalid · 429.
Granularity & comparison (penting buat charting): - Insights /data default granularity = harian (show_date=true). Butuh mingguan/bulanan/tahunan → pakai breakdown_interval=week|month|year — server-side, nol perlu agregasi di sisimu (SV-C6; year benar sejak 2026-08-28). Orders /orders/periodic sama: breakdown_interval=day|week|month|year native. ⚠️ Roll-up ke periode yang nol ada di daftar itu (mis. kuartal) tetap sisi integrator — §1.1. - Perbandingan antar-periode (current vs previous, delta %): fetch 2 range terpisah lalu hitung sendiri — tidak ada param compare built-in.
Keys inside data[] are not the metric codes you send or see in /metrics. They are rewritten server-side before serialisation. The style differs per endpoint — and in one case per report — so map per endpoint, not once globally.
| Endpoint | Keys in data[] |
Can you derive them from the metric code? |
|---|---|---|
GET /{platform}/data — facebook · instagram · tiktok · shopee_ads |
Title Case | ⛔ No — from our DB |
GET /gadwords/data (incl. group_by / breakdown_interval) |
Title Case | ⛔ No — from our DB |
GET /tiktok/gmv/periodic · table · product-items · livestreams · live-durations · product-durations |
Title Case | ✅ Yes — mechanical |
GET /tiktok/gmv/overview |
snake_case | ✅ |
GET /tiktok/gmv/creatives |
snake_case (summary / rows / metrics_available) |
✅ |
GET /ganalytics/data |
snake_case (total_users, events_per_session) |
✅ |
GET /{platform}/orders/* |
snake_case (order_cancel, total_sales) |
✅ |
GET /{platform}/catalog/* |
snake_case (our service’s own keys, e.g. shop_gmv) |
✅ |
GET /tiktok/combined |
snake_case | ✅ |
GET /{platform}/metrics · /ad-accounts |
snake_case | ✅ |
🔴 gmv is split inside one family: overview and creatives return snake_case, the other six reports return Title Case. This is the easiest thing on this API to get wrong.
Where Title Case comes from, in priority order:
date / campaign / objective → always exactly Date / Campaign / Objective.title field you already see in GET /{platform}/metrics). This is why the key is often not a mechanical transform of the code.code.replace("_", " ").title().🔴 For /data and /gadwords/data, do not compute the key from the code — read it from GET /{platform}/metrics. That endpoint returns code and title for every metric, and the title is the response key. Measured: 418 of 575 metric titles across the nine platforms are not equal to the mechanical transform of their code, so guessing fails in the majority of cases:
| Metric code | Response key | What a mechanical guess would give |
|---|---|---|
cost_micros (gadwords) |
Spend |
Cost Micros |
absolute_top_impression_percentage (gadwords) |
Impressions (Abs. Top) |
Absolute Top Impression Percentage |
conversion_rates (gadwords) |
Conversion Rate |
Conversion Rates |
total_balance (shopee_ads) |
Ads Balance |
Total Balance |
broad_cr (shopee_ads) |
Broad Conversion Rate |
Broad Cr |
affiliate_attributed_gmv (tiktok_shop) |
Affiliate GMV |
Affiliate Attributed Gmv |
live_click_to_order_rate (tiktok_shop) |
Live Click→Order Rate |
Live Click To Order Rate |
aov (shopify) |
AOV |
Aov |
⚠️ Acronym casing is not consistent between endpoints, and that follows from the table above, not from randomness. roi appears as Roi under /tiktok/gmv/* (mechanical fallback, which does not know acronyms) while roas appears as ROAS under /gadwords/data (it has a title). Do not normalise one from the other.
⚠️ Titles can contain characters you may not expect in a key: spaces, parentheses (Value per purchase (website)), and even an arrow (Live Click→Order Rate). Treat keys as opaque strings; do not assume they are identifier-safe.
If two different metric codes share the same title and both appear in the same response, the first one keeps the plain title and the second becomes "<Title> (<code>)".
Today this happens in exactly one place on the live API:
GET /instagram/data
→ "Follower Count" ← metric code follower_count
→ "Follower Count (followers_count)" ← metric code followers_count
Which one gets the bracketed form is stable (we order metrics deterministically), so you can hard-code it — but if you map from /metrics, look up both titles and expect the bracketed variant for the second of any duplicate pair.
gadwords — grain, and what is NOT the grainGET {service_base}/api/v3/public-access/gadwords/data?group_by=<dimension> returns {data: [...], pagination: {...}}, one row per dimension value, sorted ascending by the grouping key (deterministic since 2026-08-28 — before that the row order was not guaranteed at all).
group_by |
one row per… | note |
|---|---|---|
campaign |
campaign ID | new 2026-08-28. ⚠️ Use group_by=campaign; the boolean campaign=true returns 422 here — see §8.1. |
keywords |
(keyword, match_type) | Google Ads treats these as one unit. The same keyword text appears once per match type — correct, not duplication. |
adgroup |
ad group ID | ⚠️ Adgroup (the name) may repeat: 673 of 1,335 ad groups (50.4%) share a name with another (147 colliding names; one name covers 73 ad groups). Adgroup Id is the unique one. |
ad_shown |
(type, placement) | ⚠️ Adgroup/Adgroup Id on these rows are arbitrary picks, not the grain — see below. Requires channel_type=display\\|demand_gen\\|video. |
age · audience_segment |
(dimension, campaign, ad group) | composite — granular by design |
ad_performance |
ad ID | |
search_term · gender · yt_ad_performance |
that single dimension | yt_ad_performance requires channel_type — see §8.1 |
🔴 Three of these returned ONE row before 2026-08-28. adgroup, ad_shown and keywords grouped on a field our ingest never wrote, so every document collapsed into a single bucket: you received one row containing the account-wide total, labelled with an arbitrary value, at HTTP 200 with no error — indistinguishable from “this account only has one ad group”. Fixed: measured on one account, keywords 1→281, adgroup 1→37, ad_shown 1→52,652. No output field changed. If you cached those single rows, or built logic assuming one row per dimension, re-fetch — the old number was wrong, not the new one.
🔴 ad_shown rows carry Campaign, Campaign Id, Adgroup, Adgroup Id that are NOT part of the grain. Measured over 52,652 buckets: Campaign/Campaign Id happen to be deterministic (no bucket spans more than one campaign), but Adgroup/Adgroup Id are arbitrary in 26,790 rows (50.9%) — up to 4 ad groups share a single (type, placement) bucket and you receive one of them. Do not join or aggregate on Adgroup Id from ad_shown rows. For the per-ad-group split, use group_by=adgroup. (Same caveat as Publisher Platform/Platform Position on group_by=ad, §7.1.)
⚠️ campaign grain note. Rows are keyed by campaign ID. Before 2026-08-28 they were keyed by campaign name, which was wrong in both directions: 31 Campaign Id values appeared on more than one row (up to 4× — a renamed campaign split into several rows) and 22 campaigns were missing from the output entirely (their metrics merged into another campaign’s row); 961 campaigns produced 975 rows. If you fetched campaign rows from our internal surface before, the row count moves (975 → 961). No output field changed and metric totals are unchanged — only the row partitioning. 🔴 Do not join or aggregate on Campaign Id from data cached before 2026-08-28 — duplicated IDs there will double-count.
⚠️ gadwords has no metrics parameter. Every row carries the full fixed set of 19 metric columns. Sending metrics= is ignored without error.
⚠️ Adgroup Id and Campaign Id are STRINGS, so ascending order is lexicographic ("10" sorts before "9"). Cast them yourself if you need numeric ordering.
GET {service_base}/api/v3/public-access/{platform}/metrics (Bearer key; platform-gated; NO external_ref)
→ 200 { "data": [ { code, title, format, decimal, calculate, metric_a, metric_b, operator,
type, adv, parent, child, source, available, caveat } … ] }
Per-platform metric catalog + config — call this before /data to learn which metrics exist, how to format them, and which ones you must derive. No external_ref, no bound account: a key scoped to that platform is enough.
Valid {platform} values (9): tiktok · facebook · instagram · shopee · shopify · tiktok_shop · gadwords · shopee_ads · ganalytics. Anything else → 404.
Fields — all 15:
| Field | Meaning |
|---|---|
code |
Machine key used in /data responses |
title |
Canonical display name — use this as your alias/canonical key (see §1.1) |
format · decimal |
How to render (currency/percent/number, decimal places) |
available |
Whether this metric is reachable from the public API for this platform right now. The definitive availability answer — don’t infer it from anywhere else. Since 2026-08-29 it is populated for all 9 platforms. ⚠️ The scope of “reachable” differs per family — see the fine print below |
source |
warehouse · live · derived — where the number comes from. No longer null anywhere (2026-08-29), and only the five insights platforms can return live — see the fine print below |
caveat |
Per-metric warning, if any. Read before rendering |
calculate · metric_a · metric_b · operator |
The formula, for metrics you must derive yourself. ⚠️ Do not read calculate as “is derived” — see the fine print below |
type · adv |
Metric classification / advanced flag |
parent · child |
Metric hierarchy, where one exists |
Fine print on available and source (updated 2026-08-29):
available — whether the metric is reachable from the public API for that platform right now. This is now the definitive answer for all 9 platforms.
⚠️ What “reachable” means depends on the family, and the difference matters:
| Family | available: true means |
|---|---|
insights (facebook · instagram · tiktok · gadwords · shopee_ads) |
the metric appears in GET /{platform}/data |
orders (shopee · shopify · tiktok_shop) |
the metric appears in any public-access endpoint for that platform — these have no /data |
The orders wording is deliberate. tiktok_shop’s catalog is the analytics vocabulary, and those numbers are served by /{platform}/catalog/product-performance, not by /orders/*. Scoping available to the orders endpoints alone would mark 13 metrics that we genuinely serve as false.
Current shape (2026-08-29): shopee 7 of 44 · shopify 8 of 8 · tiktok_shop 14 of 90.
false = listed in the catalog, not served — for insights usually because source: live (needs a platform live-fetch we don’t proxy), for orders because no endpoint emits it.null = “unknown, ask us”, never “not available”. 🔵 As of 2026-08-29 no platform returns null any more, but the value stays valid in the contract, so keep treating it as unknown rather than as false.source — warehouse | live | derived.
derived — we compute it from other metrics; the formula is in metric_a / metric_b / operator.live — needs a live call to the ad platform, which we do not proxy. In practice these are the metrics you will find available: false. Only the five insights platforms can return live; shopee, shopify, tiktok_shop and ganalytics are warehouse-only on our side, so they never do.warehouse — we hold the underlying data and aggregate it.null for source any more (it was null for shopee, shopify, tiktok_shop and ganalytics until 2026-08-29).⚠️ Do not read calculate as “is derived”. That flag is inconsistent across platforms in our catalog — shopee has 11 metrics with calculate=1 but only 4 with an actual formula. Use the presence of metric_a + metric_b instead; that is what our own classification uses.
📄 Can’t call the API yet (e.g. no key provisioned, or a docs/research pass)? A static snapshot of the catalog per platform is published alongside this guide — see the
metric-catalog/folder next to it. The snapshot is a point-in-time dump for reading and planning; the live endpoint above is always authoritative. Check the snapshot’s own header for when it was generated.
Errors: 401 invalid key · 403 platform not granted to this key · 404 unknown platform.
GET {service_base}/api/v3/public-access/{platform}/orders/{overview|periodic|distribution}
?account_id=&external_ref=&date_start=&date_end=&breakdown_interval=day|week|month|year
overview→summary dict · periodic→series (breakdown_interval day/week/month/year) · distribution→breakdown table. Errors: 401 · 403 account not owned · 404 ref unknown / unknown report · 422 ref empty/unscoped · 422 invalid breakdown_interval (new 2026-08-28 — this family previously accepted anything and silently bucketed by day).
Note: this consume endpoint itself is still 503-gated for shopee/shopee_ads pending Fase-3.2 (RL-6) — that hasn’t changed. Separately, binding (/oauth/start?platform=shopee / shopee_ads) is live in staging sandbox for both: Shopee’s ERP ingest (orders/product/model/comment/shop_performance/category/logistics_channel/escrow) is validated end-to-end; Shopee Ads’ ingest is now validated too post re-bind (CPC + ads_balance confirmed) — campaign/campaign_hourly remain unverified only because the sandbox has zero active campaigns (not a bug, not claimed live). Production bind for both remains 503 until the same RL-6 gate lifts. shopify stays fully disabled (bind + consume).
GET {service_base}/api/v3/public-access/{platform}/catalog/{product-catalog|product-performance}
?account_id=&external_ref=&date_start=&date_end=&sort_by=gmv|units&sort_dir=asc|desc&page=&page_size=
Availability differs by platform — asking for a report the platform does not have returns 422, not 404, so you can tell a typo from a gap:
| Platform | product-catalog |
product-performance |
|---|---|---|
tiktok_shop |
✅ catalog + per-SKU stock/price | ✅ per-product with channel split |
shopee (ERP) |
⛔ 422 |
✅ per-SKU + shop_gmv |
Response: {data: [...], pagination: {...}, summary: {...}}. summary shape differs per report on purpose — catalog metrics + diagnosis counts (8 keys) · shop aggregate (54 keys) · {"shop_gmv": n} for shopee. Read it as a per-report block, not a fixed schema.
date_start/date_end are IGNORED for product-catalog. That collection is a snapshot with no top-level date; filtering on the timestamps inside the payload answers a different question (“products updated in range X”), not “my products”. Sending a range is harmless — it is not validated and not applied.use_compare here, by design (§1.1 — comparison is your side). Consequently sort_by=growth returns 422 with an explicit reason rather than quietly falling back to gmv. Fetch two periods and compute growth yourself.shopee ERP data is HISTORICAL — orders run 2022-02-17 → 2025-01-05. The default range (last 30 days) returns an empty data[]. That is the state of the data, not a broken endpoint. Send an explicit historical range.bestsellers report. The internal surface by that name is a market/competitor ranking (one account contains 326 distinct shop_ids and the account’s own shop is absent), so it is deliberately not reachable from a namespace that means “your products”.shopee_ads is not covered — the Shopee Open Platform account is restricted (see §8.1).GET {service_base}/api/v3/public-access/tiktok/combined
?account_id=&external_ref=&date_start=&date_end=
→ 200 { "data": { "ads", "shop", "blended", "channels", "warnings", "_note", … } }
TikTok-only (platform lain → 404). account_id = akun Ads GMV Max; sisi Shop di-resolve otomatis via store_id (jangan kirim). Filter dulu pakai combined_available di C1. Di belakang feature-flag TIKTOK_COMBINED_ENABLED (default OFF → 404); ON di staging per 2026-08-21, prod belum. Shape masih bisa berubah sampai GA — jangan jadikan dependensi keras.
Field penting di blended: - blended_roas = shop_total_gmv / ad_spend — HEADLINE, satu-satunya metrik basis-konsisten. - ads_attribution_coverage — UN-CLAMPED, bisa >1.0. ⚠️ Ini cakupan pencatatan atribusi iklan (dipengaruhi basis-atribusi + panjang window + coverage data toko), BUKAN kontribusi/efektivitas kausal iklan. Jangan render sebagai “paid share”. - coverage_sufficient (bool) — kalau false, JANGAN tampilkan ads_attribution_coverage sebagai angka (“data tidak cukup”). Ini yang cegah artefak ekstrem (toko history 1-hari lawan ads 90-hari → rasio 70×). - channels = directional, SUDAH de-overlap (affiliate_other = affiliate_total − live − video, bisa negatif). ⚠️ JANGAN dirender pie/stacked 100% (channel mentah overlap ~2× total → langgar anti-double-count). Cek channel_deoverlap_anomaly. - warnings (kode: coverage_insufficient/attribution_exceeds_shop_total/channel_overlap_anomaly) buat mesin; _note (statis) buat manusia. Kode tak dikenal = perlakukan generik, jangan error.
🔴 BREAKING vs kontrak lama (CMB-S4c, 2026-08-20): ad_contribution_pct (cap ≤1.0) + ad_attribution_ratio (raw) DIHAPUS → ganti ads_attribution_coverage. Konsumer di staging kudu pindah sekarang (flag udah ON). ads_attributed_gmv dan shop_total_gmv TIDAK BOLEH dijumlah (overlap sistem beda — correlate directional, bukan rekonsiliasi). Detail penuh: api-contract/public-access.md §/combined. Shopee Combined (ERP×Ads) = belum (data-coverage gated).
The bind platform string differs from the consume path (account_platform vocab). Grant your key with the allowed_platforms value; the guard maps consume-vocab↔︎grant-vocab automatically (gadwords↔︎google_ads, ganalytics↔︎google_analytics).
platform (bind /oauth/start) |
allowed_platforms (grant) |
consume path / family | Status |
|---|---|---|---|
google |
google_ads |
/gadwords/… (insights · channel_type + periode/table SV-C6) |
Live — bind + consume (RL-4/SV-C6) |
google_analytics |
google_analytics |
/ganalytics/… (insights, report=) |
Live — bind + consume (RL-3) |
meta |
facebook |
/facebook/… (insights) |
Live — bind + consume |
instagram |
instagram |
/instagram/… (insights) |
Live — bind + consume |
tiktok |
tiktok |
/tiktok/… (insights, multi-objective: awareness/traffic/engagement/conversion/video/product-sales) |
Live — bind + consume |
tiktok (GMV Max) |
tiktok |
/tiktok/gmv/… (overview·periodic·table·product-items·livestreams·live-durations · +Kelas B creatives·product-durations) |
Live — bind + consume (RL-8 + A1). Kelas B (creatives/product-durations) = code-live, data pending (200-empty) |
tiktok (Ads × Shop) |
tiktok |
/tiktok/combined (blended ROAS + coverage + channels) |
⚠️ Flag-gated preview — ON staging 2026-08-21, prod belum. Shape belum GA. Lihat C5 |
tiktok_shop |
tiktok_shop |
/tiktok-shop/… (commerce + analytics) |
Ingest live (internal sync) · consume = Fase-3.2 |
shopee |
shopee |
/shopee/… (ERP: orders/product/buyer/finance/ops) |
Bind live (sandbox e2e) · ingest ERP validated · ⚠️ consume is SPLIT — read C4b: /shopee/orders/* is 503-gated (Fase-3.2 / RL-6, no ETA) but /shopee/catalog/* is open and serves historical data (ends 2025-01-05) |
shopee_ads |
shopee_ads |
/shopee-ads/… (Ads: CPC + campaign) |
Bind live (sandbox e2e) · ingest Ads validated (CPC+balance; campaign pending active-campaign data) · consume = Fase-3.2 (RL-6) |
shopify |
shopify |
/shopify/orders (orders) |
Disabled |
tiktok_shop · shopee (ERP) |
tiktok_shop · shopee |
catalog — product/SKU: GET /{platform}/catalog/{report} |
New 2026-08-28 — see C4b |
group_by dimensions — complete list per platformFrequently asked, so listed exhaustively. Anything not on this list returns 422.
| Platform | Valid group_by values |
Count |
|---|---|---|
facebook |
objective · device · platform · placement · campaign · ad |
6 |
instagram · tiktok · shopee_ads |
objective |
1 |
gadwords |
campaign · adgroup · age · ad_performance · ad_shown · audience_segment · gender · keywords · search_term · yt_ad_performance |
10 |
ganalytics |
(uses report= instead — see C2) |
— |
Campaign breakdown for any insights platform doesn’t need group_by — use the boolean campaign=true (or objective=true). That path works for facebook, instagram, tiktok, and shopee_ads alike.
Ad-level dimensions are facebook-only, but for two different reasons — and the 422 you get says which.
device / platform / placement / campaign → the warehouse genuinely has no such rows for the others. instagram is organic-insight only; tiktok’s ad-level collection stores neither device, platform, placement, publisher_platform nor platform_position (verified field-by-field). You get 422 with a data-gap message.ad → 422 with a different message: “not enabled for ‘ad_id and ad_name (2,844,804 documents across 452 accounts). It simply hasn’t been opened yet. Ask if you need it.group_by=ad — one row per ad (new, 2026-08-28). Rows are keyed by ad_id, not by ad name:
Ad Name repeats across rows, and that is correct. Meta ad names are routinely reused — 59,883 of 121,789 distinct names (49.2%) are shared by more than one ad; one name (DPA) covers 693 ads. Do not treat Ad Name as an identifier, and do not use it as a join key. Use Ad Id.Ad Id is always a scalar string. If you ever see "id1;id2", you are talking to a build from before 2026-08-28 — that older build merged same-named ads into a single row and concatenated their ids, summing metrics across genuinely different ads.page / page_size normally. pagination.total is the true distinct-ad_id count and can exceed 1000. (The pre-2026-08-28 build capped results at 1000 and derived total from the truncated list, so it reported exactly 1000 and looked correct.)Publisher Platform / Platform Position are one representative value per ad, not a breakdown — a single ad can serve several placements inside one date range. If you need the split, use group_by=placement.Not currently retrievable (data exists upstream but is not exposed on this API — ask if you need it prioritised): ad-audience demographics (age/gender/region) for facebook/tiktok · ad creative/preview · Meta CPAS product-level rows. Google Ads age/gender is available via group_by=age|gender (note: age is keyed compositely with campaign/adgroup, so re-aggregate if you need a flat age table).
Now available (moved off the list above): per-ad rows for facebook via group_by=ad (§7.1) · product/SKU rows for tiktok_shop and shopee (ERP) via GET /{platform}/catalog/{report} (§C4b) · campaign rows for gadwords via group_by=campaign (§C2).
| Status | Meaning |
|---|---|
400 |
Raw user_id sent on external path, or unknown platform value. |
401 |
Missing / invalid / expired key (or JWT on internal path). |
403 |
purpose≠ingest, platform not in allowed_platforms, return_url not allowlisted, source_app suspended, cross-source ref, or account not owned by this end-user. |
404 |
source_app not found, external_ref unknown for this source, or unknown platform/report. |
422 |
external_ref empty/too long, key not scoped to a source_app, return_url not absolute http(s), or invalid date range / page_size. |
429 |
Rate-limited (respect Retry-After). |
503 |
Platform gated for this call — production Shopee/Shopee Ads (pending RL-6) or a fully-disabled platform (e.g. Shopify); or a consume-side platform not yet open (Shopee/Shopee Ads/TikTok Shop, all Fase-3.2). Shopee/Shopee Ads bind is live in staging sandbox — not gated there. |
These return 200 and look fine. Read them before you trust a chart.
| Caveat | Affected | What happens |
|---|---|---|
show_date is ignored |
ganalytics |
Parameter accepted but never applied. Use breakdown_interval for series here. |
show_date=true returns 422 |
gadwords |
Since 2026-08-28 it fails loudly instead of being silently dropped. Use breakdown_interval=day. |
campaign=true · objective=true · show_date=true now return 422 on gadwords |
gadwords |
Until 2026-08-28 these three were accepted and then silently dropped: you received 200 with a summary payload instead of the breakdown you asked for, with no way to tell. They now fail loudly and name the correct parameter: use group_by=campaign for campaign rows, breakdown_interval=day for a daily series. objective is not available on this platform at all (Google Ads has no objective dimension). Sending these parameters as false still returns 200 — only true is rejected. |
group_by=keywords is plural |
gadwords |
keyword (singular) → 422. search_term is a separate, distinct table. |
group_by=age is composite-keyed |
gadwords |
Rows are keyed by age × campaign × adgroup, not a flat age table. Re-aggregate client-side if you want age alone. |
yt_ad_performance is ALWAYS empty on the default channel_type |
gadwords |
channel_type defaults to search, and that collection contains 0 SEARCH documents out of 54,802 (41,864 DEMAND_GEN + 12,938 VIDEO). You get data: [], total: 0, HTTP 200. Send channel_type=demand_gen or video. This is the shape of the data, not a fault. |
ad_shown is near-empty on the default channel_type |
gadwords |
Only 409 of 1,058,915 documents are SEARCH (0.04%). Send channel_type=display (531,788), demand_gen (378,171) or video (148,547). |
audience_segment is near-empty on the default channel_type |
gadwords |
359 of 16,487 are SEARCH (2.2%). Send demand_gen (13,155), video (2,051) or display (922). |
group_by=ad rows are keyed by ad_id, and Ad Name repeats |
facebook |
49.2% of ad names are shared by more than one ad. Two rows with the same Ad Name are two different ads — not a duplicate, and not a pagination bug. Join and de-duplicate on Ad Id, never on Ad Name. |
| Response keys are not the metric codes | all insights platforms (/data, /gadwords/data) |
Keys inside data[] are the metric titles from our catalog, not the codes you request. 418 of 575 titles are not a mechanical transform of the code (cost_micros → Spend), so a snake_case mapping finds nothing. Read code and title from GET /{platform}/metrics and key off title. Other families (orders, ganalytics, catalog, combined, gmv/overview, gmv/creatives) return snake_case — see §C2. |
available means “reachable”, and the scope differs by family |
all platforms | For insights platforms it means “appears in /data”. For the three orders platforms (shopee, shopify, tiktok_shop) it means “appears in any public-access endpoint for that platform” — those have no /data, and tiktok_shop’s metrics are served by /{platform}/catalog/product-performance. Counting only the orders endpoints would mislabel 13 metrics we do serve. See §C3. |
product-catalog ignores your date range |
tiktok_shop |
The catalog collection is a snapshot with no top-level date. date_start/date_end are accepted, not validated, and not applied — you get the current catalog regardless. Do not read a date-filtered meaning into it. |
shopee ERP catalog data is historical (ends 2025-01-05) |
shopee |
product-performance on the default range (last 30 days) returns data: [] with 200. Not a fault — the newest order in the warehouse is 2025-01-05. Send an explicit historical range, e.g. 2024-01-01→2024-03-01. |
/{platform}/catalog/* hard-fails if our cache is down |
tiktok_shop · shopee |
Unlike the rate limiter (which fails open), this family’s cache layer has no fallback: if our Redis is unavailable you get 500, not degraded-but-served. Retry with backoff rather than treating it as a data error. Being addressed on our side. |
/tiktok/gmv/creatives returns empty blocks |
tiktok |
Route is live, ingest is still gated → {summary: [], rows: []}. Not an error; data pending. |
/tiktok/gmv/product-durations has no date range |
tiktok |
The level is date-less by nature. Don’t attach a date picker. |
Pre-2026-08-28 builds silently degraded year to DAILY |
facebook · instagram · tiktok · shopee_ads |
Fixed 2026-08-28: year now buckets per year (_id = "2025"). On older deployments it returned 200 with daily buckets and no error. If you are reconciling numbers across environments, check _id shape before trusting a year-over-year chart: YYYY = correct, YYYY-MM-DD = old build. |
Pre-2026-08-28 builds accepted ANY breakdown_interval on /orders/* |
shopee · shopify · tiktok_shop |
The orders family had no interval validation at all — quarter, a typo, or an empty string all returned 200 with daily buckets. Fixed 2026-08-28: anything outside day\\|week\\|month\\|year now returns 422. If your integration was sending an unrecognised value and “working”, it was silently giving you daily data — re-check the granularity you actually charted. |
| Pre-2026-08-28 builds returned time series in NON-DETERMINISTIC order | facebook · instagram · tiktok · shopee_ads · gadwords |
7 of 16 periodic pipelines had no $sort at all, so rows came back in whatever order the aggregation emitted — measured on a 92-day range: 39 of 91 consecutive pairs went backwards, and the order changed between identical calls. Two consequences: (a) your chart is plotted out of order unless you sort client-side; (b) pagination leaked — pages were sliced in memory while the aggregation re-ran per request, so a row could appear on two pages or vanish from both while pagination.total stayed correct. Fixed 2026-08-28: data[] is now sorted ascending by _id. If you added a client-side sort as a workaround, it is now redundant but harmless. |
| Pre-2026-08-28 builds merged same-named ads | facebook |
Older deployments returned one row per ad name with ids concatenated ("id1;id2") and pagination.total capped at 1000. If you see either symptom, you are not on the current build — re-check before reconciling numbers. |
group_by=adgroup · ad_shown · keywords returned ONE row before 2026-08-28 |
gadwords |
Those three dimensions grouped on a field our ingest never wrote, so every document collapsed into a single bucket: you received one row containing the account-wide total, labelled with an arbitrary value, at HTTP 200 with no error. It was indistinguishable from “this account only has one ad group”. Fixed 2026-08-28 — measured on one account: keywords 1→281, adgroup 1→37, ad_shown 1→52,652. No output field changed. If you cached those single rows, or built logic that assumed one row per dimension, re-fetch: the old number was wrong, not the new one. |
Shopee status (2026-08-27): the Shopee Open Platform account is currently restricted upstream (severe call quota). Shopee consume stays 503-gated and there is no ETA for RL-6. Plan Shopee via a fallback path rather than scheduling it against this API.
?status=.external_ref (your namespace). Never send a raw BDDAI user_id.return_url must exactly match an allowlisted prefix (default-deny).403)./data, metric discovery /metrics). Google Ads (RL-4) & GA4 (RL-3, report=) now consumable via external key — guard maps gadwords↔︎google_ads / ganalytics↔︎google_analytics.breakdown_interval=day|week|month|year (gadwords + tiktok-GMV: year → 422, deliberate — see §C2), group_by (insights: objective; ad-level device/platform/placement/campaign/ad = facebook SAJA, ig/tiktok data-gap 422; gadwords: 10 dimensi), filter_by/filter/filter_exclude (sampai level campaign). GA4 periode via breakdown_interval (Slice C)./tiktok/data full metric set (bukan curated-20 lagi).campaign/campaign_hourly unverified only for lack of an active sandbox campaign, not a bug), but production bind stays 503 pending RL-6 and consume is not yet available for either until Fase-3.2. shopify remains fully disabled (bind + consume)./tiktok/combined) = flag-gated preview (ON staging 2026-08-21, prod belum); shape belum GA. ads_attribution_coverage = cakupan pencatatan, un-clamped, BUKAN kontribusi kausal — hormati coverage_sufficient (jangan tampilkan angka kalau false) + jangan jumlahkan ads vs shop GMV + jangan render channel sebagai 100%-stack. Field lama ad_contribution_pct/ad_attribution_ratio dihapus (CMB-S4c). Lihat C5. Shopee Combined belum (data-coverage gated)./overview, live-API passthrough.demo-middleware harness in this repo (backend/app) shows the full loop end-to-end.