# 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.0 · 2026-07-15 · Status: as-built (IN-6 `external_ref`, shipped).**
> A formatted `.docx` of this guide is available alongside this file.

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

- **Ingest (bind):** your backend calls the OAuth broker; the end-user consents on the platform; the access token is exchanged and stored **server-side by BDDAI** — never returned to your app.
- **Consume (read):** your backend reads the bound account list and their metrics via Public Access, scoped to one end-user.
- **Addressing:** you identify each end-user with **your own reference string** (`external_ref`); BDDAI maps it to an internal user id. See §4.

## 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_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.

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

- **Idempotent:** the same `external_ref` always maps to the same end-user (no token replacement).
- **Isolated:** the same `external_ref` string under a different source_app is a different end-user — apps can never reach each other's users.
- **Raw `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.**

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

```mermaid
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**:

- **Token expiry (automatic):** BDDAI refreshes the access token server-side using the stored refresh token. Your app does nothing.
- **Re-consent** (revoked / scope change / refresh token invalid): call `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.**

## 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 (e.g. Shopee).

### 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" } ] }
```
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)
```
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.

### C3. Discover metric catalog (MV-2)
```
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.

### C4. Fetch orders report (shopee / shopify — currently 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
```
`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.

## 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) | Live — bind + consume |
| `shopee` | `shopee` | `/shopee/orders` (orders) | Gated (`503`) |
| `shopify` | `shopify` | `/shopify/orders` (orders) | Disabled |

## 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 is wired but gated (e.g. Shopee). |

## 9. Security rules (must-follow)

- Keep the key **server-side**. Every BDDAI call is made by your backend, never the browser.
- Access tokens are **never** returned to your app — the redirect back only ever carries `?status=`.
- Address end-users **only** by `external_ref` (your namespace). Never send a raw BDDAI `user_id`.
- `return_url` must **exactly match** an allowlisted prefix (default-deny).
- An app can **never** reach another source_app's end-users (cross-source guard → `403`).

## 10. Notes & current limitations

- **Meta / Instagram / TikTok / Google Ads / GA4** = full bind + consume (account-list, insights `/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`.
- **SV-C6 landing** — periode + table/breakdown + filter server-side: `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).
- **MV-1 landing** — TikTok metric cap dibuka → `/tiktok/data` full metric set (bukan curated-20 lagi).
- **Period comparison** (current vs previous / delta) = fetch dua range lalu hitung client-side (nol param compare built-in).
- Data is **warehoused** (not a live-API passthrough); max date span **92 days** per call.
- Charts are rendered **by your FE** — the API returns the underlying data (summary / daily-series / breakdown-table) needed for it, same shapes internal dashboards use.
- Orders platforms (Shopee gated `503` / Shopify disabled) are not yet consumable.
- Not exposed externally (internal-only): custom-report, budget-tracker, aggregate `/overview`, live-API passthrough.
- Reference implementation: the `demo-middleware` harness in this repo (`backend/app`) shows the full loop end-to-end.
