# 08Liter CRM — Integration Guide for AI Agents & External Dashboards

**Base URL**: https://hub.08liter.com
**Auth**: `Authorization: Bearer crm_xxx` (or `X-API-Key: crm_xxx`)
**Machine-readable**: [/api/tools](https://hub.08liter.com/api/tools) · [/openapi.yaml](https://hub.08liter.com/openapi.yaml) · [Postman](https://hub.08liter.com/postman.json)

> **Provisioning a brand partner + launching a 구매평(PYBK) campaign?** That is a separate
> ServiceJWT flow — see **[/docs/pybk](https://hub.08liter.com/docs/pybk)** (partner ensure →
> charge → campaign create → activate → status). This CRM guide below is read/analytics + B2B send.

**Transport is HTTPS.** All endpoints listed in this guide are served over
TLS at `hub.08liter.com:443`; plain `http://` requests redirect to
`https://`. There is no plaintext API.

This is a plain **JSON-over-HTTPS API**: every tool is `POST /api/tools/{name}`
with a JSON body. The same surface is *also* exposed as an MCP stdio server
for agents that prefer that protocol — but if you're an LLM with HTTPS
access, just call the HTTPS endpoints directly. There is no MCP-only
behavior.

You only need a key and this URL. Everything else is discoverable from
`/api/tools`, `/openapi.yaml`, and `/postman.json` (all three linked above).

---

## 1. What this system is

08Liter CRM platform with two clearly-separated capabilities:

1. **Read (analytics, reporting, dashboards)** — full schema is queryable
   read-only. You can pull anything: brands, campaigns, partners, **and B2C
   members / liter history / apply / review tables**. SQL is your friend
   (`execute_read_sql`) when the curated query tools don't fit.
2. **Marketing send (B2B-only)** — the only outbound path is a human-reviewed
   email gate. Drafts are addressed to `partner_admin` (법인 담당자) and
   approved by a human at `/drafts/{id}`. The send path **rejects any
   recipient that isn't a verified partner_admin**, so B2C read access
   cannot be turned into a B2C send. Marketing to consumer reviewers is
   simply not implemented and never will be on this surface.

If a request looks like reporting / analytics / "show me X", it's almost
always a read tool. If it's "draft an email / send / contact", it's the
B2B review-gate workflow.

---

## 2. Terminology (critical — read before calling tools)

| Business word (ko)    | English / DB table       | Role                                                  |
|-----------------------|--------------------------|-------------------------------------------------------|
| 법인 회원              | partner (B2B account)    | The legal entity. `partner.id` = owning account id.   |
| 브랜드                 | brand                    | Marketing unit **owned by a partner** (N brands : 1 partner). `brand_id`, `partner_id`. |
| 법인 담당자            | partner_admin (contact)  | Human inside the partner. **This is who receives email.** Must have verified email. |
| 캠페인                 | campaign                 | A single marketing run by a partner.                   |
| 일반 회원 / 리뷰어     | member (B2C)             | **READ is fine** — analytics, dashboards, liter rankings, top reviewers, etc. The marketing send path simply doesn't accept B2C recipients (server enforces). |

**Mapping rules the agent MUST follow**:

- User says "브랜드 목록" → call `search_brands` (returns brand rows joined to partner).
- User says "이 법인에게 메일 보내" → recipient is `partner_admin.email`, `externalId` is `partner.id`.
- User says "리뷰어 / 일반회원 / 멤버 분석" → `get_ddl` to discover columns,
  then `execute_read_sql` against `member`, `member_liter_transaction`,
  `campaign_apply`, `campaign_review`, etc. Reports and dashboards welcome.
- User says "리뷰어한테 메일 보내" → that's not a thing here. Tell the user
  the system is B2B-send-only and offer the analytics alternative.
- Never put `member.id` in `externalId` of write tools (`create_segment`,
  `save_message_draft`) — the send path will reject it anyway.
- Never invent an email — use only `primary_contact_email` from `search_brands`
  or a row from `search_partner_contacts`.

Status codes you'll see on `campaign.status_code`:

| Code   | Meaning            | Korean            |
|--------|--------------------|-------------------|
| `1206` | Accepting applies  | 참여자 모집중      |
| `1208` | Under review       | 심사 중           |
| `1209` | Closed / completed | 완료              |
| `1210` | Cancelled          | 취소              |
| `1211` | Rejected           | 반려              |

Top-level category ids (use `list_categories` to resolve children):

| id     | 이름      |
|--------|-----------|
| `12`   | 뷰티       |
| `13`   | 패션       |
| `14`   | 라이프     |
| `33`   | 식품       |
| `34`   | 유아       |
| `35`   | 취미       |
| `6666` | 반려동물    |

---

## 3. How to connect

### A. HTTPS (recommended — any client: agents, dashboards, curl, fetch, Postman)

```bash
curl -X POST https://hub.08liter.com/api/tools/search_brands \
  -H "Authorization: Bearer crm_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"categoryId": 12, "hasVerifiedContact": true, "limit": 10}'
```

That is the entire integration. `GET /api/tools` lists every tool;
`GET /api/tools/{name}/detail` returns the JSON Schema for one tool.

### B. MCP stdio (only if your agent runs MCP — Claude Cowork / Claude Desktop)

```json
{
  "mcpServers": {
    "08liter-crm": {
      "url": "https://hub.08liter.com",
      "authorization": "Bearer crm_YOUR_KEY"
    }
  }
}
```

Tool names are identical either way. Most callers (incl. ChatGPT, Codex,
custom dashboards) should pick **A**.

---

## 4. Tool catalog — 18 tools across 5 categories

Full machine-readable spec: `GET /api/tools` (catalog) or
`GET /api/tools/{name}/detail` (single tool with JSON Schema).

### explore (read — schema discovery + ad-hoc SQL)
- `describe_schema` — Quick-look at the curated CRM tables (B2B + B2C analytics) with row counts. Pass any base table name to get its columns.
- `get_ddl` — Full-schema introspection. Empty args → list every base table (curated flagged + all others). With `table` → CREATE TABLE DDL + columns + indexes.
- `execute_read_sql` — Ad-hoc read-only SQL (SELECT / WITH / SHOW / EXPLAIN / DESCRIBE). **Any base table is queryable, including `member` and liter history.** Multi-statement and any write keyword rejected; `SET SESSION TRANSACTION READ ONLY` is applied; rows clamped to `MCP_MAX_ROW_LIMIT` (currently 10000 in production); per-query timeout. Full input + output summary (executed SQL, columns, row count, truncation flag) audit-logged.

### query (curated read tools — 08L MariaDB)
- `list_categories` — Canonical category tree, resolves ids to names.
- `search_brands` — Brand finder (the main B2B entrypoint). Joins partner + one verified contact.
- `get_brand` — Deep-dive: brand + partner + ALL contacts + recent campaigns + liter balance.
- `search_campaigns` — Campaign finder by partner / status / date / apply_count.
- `search_partner_contacts` — ALL verified contacts for a partner (use for CC / multi-recipient).
- `get_campaign_performance` — Fill rate / oversubscription ratio for a single campaign.

### query (read crm-pg state)
- `list_segments` — Existing saved target lists.
- `list_drafts` — Drafts in progress (filter by status).
- `list_menu_groups` — Dynamic admin menus.

### author (write crm-pg)
- `create_segment` — Freeze a target list from search results.
- `save_message_draft` — Per-target personalized emails, status='draft'.

### control
- `queue_for_review` — Transition draft → in_review. **This is the final step the API exposes.** Humans approve at `/drafts/{id}`.

### admin
- `create_menu_group`, `create_menu_item`, `upsert_dynamic_page` — Build admin UI pages from tool results.

---

## 5. Two canonical workflows

### Workflow A — Read / analytics (no human gate, just data)

```
1. (optional) describe_schema { }                    → curated tables overview
2. (optional) get_ddl { table: "member" }            → exact columns
3. execute_read_sql {
     sql: "SELECT ... FROM member m JOIN ... WHERE ...",
     limit: 1000   // optional; default 1000, max = MCP_MAX_ROW_LIMIT (10000)
   }
```

Examples:
- "전체 브랜드 수 + 최근 캠페인 진행 10개 업체" → `execute_read_sql` (or
  `search_brands` + `search_campaigns`).
- "최근 1년 리터 많이 받은 리뷰어 TOP 10" → `execute_read_sql` over
  `member` + `member_liter_transaction`. **B2C is allowed for read.**
- "카테고리별 캠페인 평균 신청자 수" → `execute_read_sql` GROUP BY.

### Workflow B — B2B email draft (human review gate)

Given a natural-language request like:

> "뷰티에서 2025-01-01 이후 캠페인 없는 법인 회원 10개 찾아서, 각각
> 맞춤 재활성화 메일 만들고 리뷰큐에 올려줘. 발송은 하지 마."

```
1. list_categories { language: "ko" }                → 뷰티 = 12
2. search_brands { categoryId: 12, lastCampaignBefore: "2025-01-01",
                   hasVerifiedContact: true, limit: 10 }
3. (optional) get_brand { brandId }                  → personalization context
4. create_segment { name, kind: "partner", targets: [...] }
5. save_message_draft { segmentId, name, subjectTemplate, bodyTemplate,
                        targets: [{ externalId, toEmail, toName, subject, body, context }] }
6. queue_for_review { draftId, note }                → human at /drafts/{id}
```

**Never** add a send step. The HTTP API has no send tool — outbound email
only happens when a human clicks Approve in the web UI.

---

## 6. Response envelope (every HTTP response)

Success:
```json
{
  "ok": true,
  "tool": "search_brands",
  "data": { ... tool-specific payload ... },
  "count": 10,
  "request_id": "req_abc123",
  "took_ms": 234
}
```

Failure:
```json
{
  "ok": false,
  "error": {
    "code": "E_VALIDATION",
    "message": "Input validation failed",
    "details": [{ "path": ["categoryId"], "message": "Expected number, received string" }]
  },
  "request_id": "req_abc123",
  "took_ms": 5
}
```

### Error codes the agent must handle

| Code              | HTTP | Retry?            | Agent action                                                 |
|-------------------|-----:|-------------------|--------------------------------------------------------------|
| `E_VALIDATION`    |  400 | No                | Fix args using `details[]` paths; retry with corrected input |
| `E_AUTH_MISSING`  |  401 | No                | Attach `Authorization: Bearer crm_xxx`                       |
| `E_AUTH_INVALID`  |  401 | No                | Key typo or revoked — ask operator for new key                |
| `E_AUTH_EXPIRED`  |  403 | No                | Request new key                                               |
| `E_AUTH_REVOKED`  |  403 | No                | Contact admin                                                 |
| `E_SCOPE`         |  403 | No                | Key lacks tool category; ask admin to widen scopes            |
| `E_NOT_FOUND`     |  404 | No                | Tool name typo, or referenced entity missing                  |
| `E_CONFLICT`      |  409 | Only after refetch | State changed (e.g. draft already queued) — refetch + decide  |
| `E_IDEMPOTENCY`   |  409 | No                | Same Idempotency-Key reused with different body — use fresh UUID |
| `E_SUPPRESSED`    |  422 | No                | Target email on suppression list — drop from list             |
| `E_RATE_LIMIT`    |  429 | Yes (backoff)     | Honor `details.retry_after_sec`                              |
| `E_DB_TIMEOUT`    |  504 | Yes (backoff)     | Narrow filters / reduce `limit`; retry once                  |
| `E_DB`            |  503 | Yes (backoff)     | Infra issue — retry with exponential backoff                  |
| `E_INTERNAL`      |  500 | Yes (once)        | Log `request_id`; report if it repeats                       |

---

## 7. Idempotency (REQUIRED for write tools)

Write tools: `create_segment`, `save_message_draft`, `queue_for_review`,
`create_menu_group`, `create_menu_item`, `upsert_dynamic_page`.

Pass a fresh UUID per **logical operation**:

```
Idempotency-Key: 7c1b3e4a-9d2f-4a8b-b3e2-1f5c9d8e2a4f
```

- Same key + same body → server returns the cached response.
- Same key + **different** body → 409 `E_IDEMPOTENCY` (use a new UUID).
- 24h TTL.

Read tools don't need this.

---

## 8. Rate limits

Default: 60 req/min per key. `E_RATE_LIMIT` (429) with
`details.retry_after_sec` when exceeded. Back off and retry.

Request a higher limit for high-throughput integrations.

---

## 9. Safety invariants (these are enforced server-side)

- **Read-only zeliter** — every MariaDB connection runs `SET SESSION TRANSACTION READ ONLY`.
- **Row cap (output only — does NOT affect intermediate computation)** —
  every query is clamped to `MCP_MAX_ROW_LIMIT` (currently **10000** in
  production) **only on the final returned rows**. `execute_read_sql`
  achieves this by wrapping your SQL in
  `SELECT * FROM (<your sql>) AS t LIMIT N`, so:
  - aggregations (`SUM`, `COUNT`, `AVG`, `GROUP BY`) run over the **full
    underlying dataset** — totals are correct regardless of the cap;
  - `JOIN`s run fully before the cap is applied;
  - `ORDER BY` sorts the full result set; you get the top N.

  When `truncated: true` appears in the response, it means there were
  more output rows than the cap — not that the computation was partial.
  Curated query tools include `clamped_by: "MCP_MAX_ROW_LIMIT"` in the
  response when triggered. If you need to page through more than the cap,
  add explicit `LIMIT/OFFSET` inside your own SQL or use the curated
  tool's `offset` parameter.
- **No send path on the API** — the HTTP gateway has no send tool. Emails
  only leave via the human-approved review gate.
- **Suppression list** — bounced/unsubscribed addresses never receive email.
- **B2B-only marketing send** — `save_message_draft` and `queue_for_review`
  reject anything that isn't a `partner_admin` recipient. B2C **read** is
  unrestricted (analytics, dashboards, liter rankings) — read access cannot
  be turned into a B2C send because the send path rejects non-partner_admin
  targets at the data layer.
- **Audit log** — every call (success OR failure) lands in `AuditEntry`
  with actor, params (input), elapsed, and (where applicable) an
  `output` summary. `execute_read_sql` audit rows include the executed
  SQL, returned column list, row count, and truncation flag — so /audit
  shows what data actually left the system. Queryable at `/audit`.

---

## 10. Quick context block to paste into Cowork / ChatGPT / any agent

Copy-paste this as the system or first user message:

> You have access to 08Liter CRM via the HTTP API at https://hub.08liter.com.
>
> Capabilities:
> - **Read anything (read-only)** — `execute_read_sql` against any base table:
>   brand, partner, campaign, member, member_liter_transaction, campaign_apply,
>   campaign_review, etc. Use get_ddl first to discover columns.
> - **B2B-only email drafts (human review gate)** — search_brands → create_segment
>   → save_message_draft → queue_for_review → human approves at /drafts/{id}.
> - **Never send email**; the API has no send tool.
>
> Key rules:
> - "브랜드" / "법인 회원" = B2B partner. Recipient is `partner_admin.email`.
> - "일반 회원" / "리뷰어" = B2C member. **Read is fine** (analytics).
>   Marketing-send is not supported — refuse any "send to reviewers" request
>   and offer analytics instead.
> - Write tools require `Idempotency-Key` header (fresh UUID per logical op).
> - On errors, read `error.code` and follow the error table.
> - Full spec: GET https://hub.08liter.com/api/tools
> - Tool schemas: GET https://hub.08liter.com/api/tools/{name}/detail
> - This doc: https://hub.08liter.com/docs/integration

---

## 11. Typical scenarios

### A. Dormant brand reactivation (B2B email draft)
> "뷰티에서 2025-01-01 이후 캠페인 없는 법인 10곳 찾아 맞춤 메일 draft 만들어줘."
→ `list_categories` → `search_brands` → `get_brand` (per brand) → `create_segment` → `save_message_draft` → `queue_for_review`

### B. High-traction campaign audit (read)
> "지난 6개월 중 신청 100건 넘은 캠페인 보여줘. 법인별로 묶어서."
→ `search_campaigns { startedAfter, minApplyCount: 100 }` → group client-side, OR `execute_read_sql` with GROUP BY for one-shot.

### C. Reach every contact at one partner (B2B email draft)
> "법인 id 695 담당자 전원한테 공지 보내줘."
→ `search_partner_contacts { partnerId: 695 }` → `create_segment` → `save_message_draft` (one target per contact) → `queue_for_review`

### D. Build a dashboard page
> "뷰티 법인 랭킹 보여주는 어드민 페이지 만들어줘."
→ `create_menu_group` (if needed) → `create_menu_item` → `search_brands` → `upsert_dynamic_page` with a `table` section.

### E. B2C analytics (read — fully supported)
> "최근 1년간 리터 많이 받은 리뷰어 TOP 10 + 어떤 캠페인에서 받았는지."
→ `get_ddl { table: "member_liter_transaction" }` → `execute_read_sql` with JOIN
across `member` + `member_liter_transaction` + `campaign`. Reports only —
the send path still rejects B2C recipients, but read is unrestricted.

### F. Free-form analytics
> "브랜드 등록 추세를 월별로 그래프로 만들고 싶어, 데이터 뽑아줘."
→ `get_ddl { table: "brand" }` → `execute_read_sql` with DATE_FORMAT GROUP BY.

---

## 12. Getting a key

API keys are issued by 08L admin via `/admin/keys` (requires ROLE_ADMIN).
Each key can have:

- **scopes**: `["explore","query"]` (read-only) or `["*"]` (full access).
- **rateLimitPerMin**: default 60.
- **allowedOrigins**: CORS whitelist for browser-direct calls.
- **expiresAt**: optional expiry.

Contact 08L ops to request or scope a key.
