BDDAI External Integration Guide

BDDAI — External Integration Guide & API Contract

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-6 external_ref, shipped). v1.12 (konsolidasi patch service 2026-08-28/29): (1) §C2 baru — penamaan kunci response: kunci di data[] = title katalog, bukan code (418 dari 575 title nol-derivable) + tabel gaya kunci per-endpoint (Title Case vs snake_case; famili gmv terbelah). (2) §C2 baru — dimensi group_by gadwords: +campaign, grain vs non-grain (ad_shown), tiga dimensi yang dulu balik 1 baris. (3) §C4b baru — product/SKU GET /{platform}/catalog/{product-catalog|product-performance} (tiktok_shop · shopee ERP). (4) §7.1: facebook +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; source nol lagi null; live cuma dari 5 platform insights. (6) §8.1: 2 baris dibuang · show_date dipecah (gadwords → 422, ganalytics masih diabaikan diam-diam) · 15 baris baru (batas versi pra-2026-08-28, jebakan channel_type gadwords, behaviour-change campaign=true422, famili catalog). (7) Koreksi: breakdown_interval=week|month|year itu 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·caveat sebelumnya 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 di metric-catalog/ sebelah guide — /metrics butuh 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 dimensi group_by LENGKAP per platform (jawaban pertanyaan berulang: facebook 5 · instagram/tiktok/shopee_ads 1 · gadwords 9). (3) §8.1 baru — known caveats: show_date no-op di gadwords/ganalytics · breakdown_interval=year silent-degrade ke harian di meta/ig/tiktok · group_by=keywords plural · gadwords age composite-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 kontrak public-access.md per 2026-08-20/21. (1) ad-accounts response nambah currency (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) — field ads_attribution_coverage un-clamped + coverage-guard, BUKAN “kontribusi iklan”; ad_contribution_pct/ad_attribution_ratio lama DIHAPUS. (3) GMV Max nambah report Kelas B creatives/product-durations (data pending, 200-empty). Regenerate .docx setelah ini. v1.3: Shopee Ads ingest sekarang validated post re-bind (CPC + ads_balance OK); campaign/campaign_hourly masih 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 .docx of this guide is available alongside this file (regenerate setelah update ini).

1. Overview

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.

1.1 Scope — what this API does, and what it deliberately does not

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:

  1. Report/block shaping. Your report’s structure (overview cards, charts, breakdown tables, carousels, funnels, product panels — whatever you call them) is your construct, not a concept in this API. There are no per-block endpoints and none are planned. Compose your blocks from the data the API returns.
  2. Period comparison. There is no 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.
  3. Naming/alias translation. Metric titles come from the metric catalog (§C3). Treat catalog title as the canonical name. Mapping it to your own display vocabulary — including reconciling aliases inside your own templates — is yours.
  4. Roll-ups beyond what’s offered. Insights granularity is daily; aggregate to your own periods unless the endpoint exposes 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 — and sort_by=growth, which needs one, returns 422. 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.

2. Prerequisites

Ask a BDDAI administrator to provision (once):

  1. Register your app as a source_app — you receive a code (e.g. partnerx).
  2. Add your return URL to that source_app’s redirect allowlist — exact prefix match, default-deny: an unregistered return_url makes /oauth/start return 403 for every request.
  3. Issue a credential (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.

3. Authentication

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_platforms403; 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.

3.5 Environments — Base URLs (direct endpoints)

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.

4. Core concept — addressing end-users by external_ref

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

You do not need to store any id BDDAI returns — keep using your own external_ref on every call.

5. Integration flow

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 }

5.1 Re-binding & refreshing access

An end-user who already bound can refresh or re-authorize without any special endpoint:

Reuse the same external_ref, no re-provisioning, no separate “rebind” endpoint. First-time and repeat binds are the exact same call.

6. Endpoint reference

A. Provision end-user

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

B. Start binding (OAuth broker)

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 (callback to your app)

{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).

C1. List bound accounts

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" } ] }

Errors: 401 · 403 platform not allowed · 404 ref unknown · 422 ref empty / key unscoped · 429 rate-limited.

C2. Fetch insights data (meta / instagram / tiktok / google-ads / GA4)

⚠️ 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 that orders, ganalytics, catalog, combined and gmv/overview use 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. - Seriesshow_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: day2026-03-15 · week2026-11 > (%Y-%U, Sunday-based, not ISO week) · month2026-03 · year2026. Parse by shape, > not by position — and do not assume week matches ISO-8601 week numbers. - Table / breakdowncampaign=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). - Filterfilter_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 → 422deliberate 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|yearserver-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.

Response key naming — read this before you map any field

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}/datafacebook · 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:

  1. Dimension columns date / campaign / objective → always exactly Date / Campaign / Objective.
  2. Our metric title table (the title field you already see in GET /{platform}/metrics). This is why the key is often not a mechanical transform of the code.
  3. Fallback, when a metric has no title: 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.

Duplicate titles: a key can end with the metric code in brackets

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.

Dimension rows for gadwords — grain, and what is NOT the grain

GET {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.

C3. Discover metric catalog (MV-2) — the definitive answer to “is metric X available?”

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):

⚠️ 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.

C4. Fetch orders report (shopee / shopify — consume still gated)

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

C4b. Fetch product/SKU (tiktok_shop · shopee ERP) — new 2026-08-28

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.

C5. TikTok Combined (Ads × Shop) — ⚠️ flag-gated preview

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_spendHEADLINE, satu-satunya metrik basis-konsisten. - ads_attribution_coverageUN-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).

7. Platform vocabulary & availability

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

7.1 group_by dimensions — complete list per platform

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

group_by=ad — one row per ad (new, 2026-08-28). Rows are keyed by ad_id, not by ad name:

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

8. Error reference

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.

8.1 Known caveats — silent-wrong behaviours to code around

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_microsSpend), 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-012024-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 allquarter, 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.

9. Security rules (must-follow)

10. Notes & current limitations