Binding accounts via the OAuth broker (ingest) and reading data via Public Access (consume), addressed per end-user by
external_ref. Version 1.0 · 2026-07-15 · Status: as-built (IN-6external_ref, shipped). A formatted.docxof this guide is available alongside this file.
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.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.
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.
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 (e.g. Shopee).
{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" } ] }
Errors: 401 · 403 platform not allowed ·
404 ref unknown · 422 ref empty / key unscoped
· 429 rate-limited.
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. - Table / breakdown
— campaign=true/objective=true
ATAU group_by=objective: row per-dimensi
(paginated). Ad-level
group_by=device|platform|placement|campaign =
facebook SAJA (ig/tiktok → 422
data-gap, warehouse ga simpan field ad-level itu). -
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,
follow-up) + group_by=<9 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 granularity =
harian (show_date). Butuh
mingguan/bulanan → agregasi di FE-mu
(server insights hanya harian). (Orders
/orders/periodic beda:
breakdown_interval=day|week|month native.) -
Perbandingan antar-periode (current vs previous, delta
%): fetch 2 range terpisah lalu hitung sendiri — tidak
ada param compare built-in.
GET {service_base}/api/v3/public-access/{platform}/metrics (Bearer key; platform-gated; TANPA external_ref)
→ 200 { "data": [ { code, title, format, decimal, calculate, metric_a, metric_b, operator, type, adv } … ] }
Katalog metric + config per platform (buat tau metric apa aja + cara
format/rumus sebelum fetch /data). Errors:
401 · 403 platform belum di-grant ke key
ini.
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
overview→summary dict · periodic→series
(breakdown_interval day/week/month) ·
distribution→breakdown table. Errors: 401 ·
403 account not owned · 404 ref unknown /
unknown report · 422 ref empty/unscoped.
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) |
Live — bind + consume |
shopee |
shopee |
/shopee/orders (orders) |
Gated (503) |
shopify |
shopify |
/shopify/orders (orders) |
Disabled |
| 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 is wired but gated (e.g. Shopee). |
?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: year=follow-up), group_by (insights: objective;
ad-level device/platform/placement/campaign = facebook
SAJA, ig/tiktok data-gap 422; gadwords: 9 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).503 / Shopify disabled)
are not yet consumable./overview, live-API
passthrough.demo-middleware harness
in this repo (backend/app) shows the full loop
end-to-end.