# 08Liter PYBK(구매평) Provisioning — Integration Guide

**Base URL**: https://hub.08liter.com
**Auth**: `Authorization: ServiceJWT <token>` (HS256, ConnectedApp shared secret)
**Audience**: external sales site that signs up brand partners and launches purchase-review campaigns.

> All calls go through the hub. The 08L backend (new-admin) only accepts the hub's
> internal credential — **direct calls to the backend are blocked**. You only ever talk to
> `hub.08liter.com`.

---

## 0. Mental model

- **You (sales site)** own your own member accounts (brand staff login/session) and the sales/payment funnel.
- **08L** owns the partner & campaign business records (source of truth). You just keep a mapping
  `(your user) → 08L partnerId`.
- You send **business values only**. 약관/계정/인증/brand/product are auto-handled by the server.

Fixed flow:

```
1) ensure partner   → partnerId 확보 (없으면 자동 가입)
2) 결제(충전)        → 08L deposit 충전. 이 충전이 캠페인을 라이브로 만드는 조건.
3) PYBK 생성         → 충전 충분 → 즉시 라이브(1209) / 부족 → 임시저장(1206, 미노출)
4) activate (필요시) → 충전 후 라이브 전환
5) status 폴링       → 모집·진행·결과 조회
```

> 🔑 **Charge gate**: a campaign is shown to reviewers (live) only when
> `deposit ≥ points × offerCount`. Otherwise it is created as TEMPORARY_SAVED (not visible) and you
> activate it after charging. This stops unapproved / uncontracted / unfunded partners from running a
> campaign they cannot pay out (rewards debit the partner deposit at review time).

---

## 1. Onboarding (one-time)

08L issues you two things:

| Item | Use |
|---|---|
| **service code** (`iss`) | your identifier, e.g. `pybk-sales`. 08L records it as the source of every signup/campaign. |
| **shared secret** | HS256 signing key for the ServiceJWT. Server-side only. |

---

## 2. Auth — ServiceJWT

Sign a short-lived (5 min) HS256 JWT **on your backend** for every call.

claims:

| claim | value |
|---|---|
| `iss` | your service code (e.g. `"pybk-sales"`) |
| `sub` | actor id (system flows: `"system"`) |
| `aud` | `"08l-hub"` |
| `jti` | a fresh UUID **per request** (replay protection — reusing a jti is rejected) |
| `exp` | now + 300s |

Header: `Authorization: ServiceJWT <token>` (note: **ServiceJWT**, not Bearer).

### Node (jose)

```js
import { SignJWT } from 'jose';
import { randomUUID } from 'crypto';

async function serviceJwt() {
  const secret = new TextEncoder().encode(process.env.HUB_SHARED_SECRET);
  return await new SignJWT({})
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuer('pybk-sales')
    .setSubject('system')
    .setAudience('08l-hub')
    .setIssuedAt()
    .setExpirationTime('5m')
    .setJti(randomUUID())
    .sign(secret);
}
// fetch(url, { headers: { authorization: 'ServiceJWT ' + await serviceJwt() } })
```

---

## 3. Idempotency / errors

- Writes (ensure, pybk) take an `Idempotency-Key` header — same key + same body returns the same result
  (24h). Use your own lead/order id.
- Error shape: `{ "code": "...", "message": "..." }` (hub-level) or
  `{ "error": { "code": "...", "message": "...", "details": {...} } }` (backend).
- Common: `401 TOKEN_*` (sign/exp/jti), `403 SERVICE_NOT_ALLOWED` (service code not registered),
  `403 PARTNER_NOT_OWNED` / `CAMPAIGN_NOT_OWNED` (you can only touch what you provisioned),
  `402` (activate while deposit insufficient), `409` (duplicate / partial match).

---

## 4. Endpoints

> **Two integration patterns**:
> - **🟢 Pattern A (recommended, since 2026-05-25)**: 1-call `onboard` (§ 4-0). Pay first via Hub Pay, then a single call creates partner + campaign + deposit charge + sends invite email. **Use this for new integrations.**
> - **Pattern B (legacy, 4-call)**: ensure → pay → pybk → activate. Existing integrations may continue, but new sites should use Pattern A.

### 4-0. 🟢 1-call onboard — **recommended**

```bash
POST https://hub.08liter.com/v1/external/pybk/onboard
Authorization: ServiceJWT <token>
Idempotency-Key: <lead/order id — stable, NOT per-request UUID>
Content-Type: application/json
```

⚠️ **Critical pre-conditions** (learned from 2026-05-26 incident):

1. **Call this AFTER Hub Pay returns PAID** — do NOT stop at payment alone. Payment without onboard
   leaves the partner/campaign/deposit/email all uncreated (the user paid and got nothing).
2. **Guard before re-payment**: if a lead/quote is already `PAID`, block the user from re-entering the
   checkout page (Hub idempotency only catches duplicate `Idempotency-Key` — different keys are
   treated as separate orders and double-charge the customer).
3. **`Idempotency-Key` must be stable** (e.g. your quote/lead id). Do NOT generate a fresh UUID per
   request — it defeats the duplicate-call guard.
4. **`partner.contactEmail` / `contactName` / `contactPhone` must be real values from your form**.
   Placeholder values (e.g. `<txid>@08liter.ai`, `<txid>@placeholder.08liter.ai`, `UNKNOWN`,
   `000-0000-0000`) trigger SES skip and leave the partner without a working login — the operator then
   has to manually correct via the admin endpoint `PUT /api/admin/partners/{id}/contact-info` and
   resend the invite. Best to just collect the real values from your sales form up front.

Body (17 fields KR / 16 abroad):

```json
{
  "partner": {
    "companyName":    "주식회사 ABC",         // required, min 1
    "country":        "KR",                  // required, enum
    "businessNumber": "123-45-67890",        // optional — 빈칸 시 KR 은 placeholder 자동, 해외는 null
    "contactEmail":   "ceo@abc.com",         // required, email format, real
    "contactName":    "홍길동",                // required, min 1, real
    "contactPhone":   "010-1234-5678",       // required, 4-20 chars, real
    "agreedAt":       "2026-05-25T10:00:00+09:00"
  },
  "campaign": {
    "productName":         "피카소 젤 아이라이너",
    "productPrice":        19900,
    "productThumbnailUrl": "https://cdn.example.com/p1.jpg",
    "channel":             "Coupang",
    "purchaseUrl":         "https://www.coupang.com/vp/products/12345",
    "offerCount":          10,
    "rewardPoints":        20600,
    "startAt":             "2026-06-01T00:00:00+09:00",
    "endAt":               "2026-06-30T23:59:59+09:00"
  },
  "payment": { "orderCode": "<Hub Pay /v1/pay/orders 응답의 orderCode>" }
}
```

> 💡 `campaign.reviewPrice` (리뷰가 — 사용자에게 노출되는 가격) is **optional**. If omitted it defaults to
> **10% of `rewardPoints`** (the payback). e.g. `rewardPoints: 20600` → 리뷰가 `2060`.

Response (200):

```json
{
  "result": "SUCCESS",
  "partner":  { "partnerId": "12377", "loginEmail": "ceo@abc.com" },
  "campaign": { "campaignId": "73254", "status": "IN_PROGRESS", "statusCode": "1209" },
  "deposit":  { "charged": 206000, "available": 206000, "shortfall": 0, "required": 206000 },
  "onboarding": {
    "emailSent":     true,
    "emailSentAt":   "2026-05-25T10:01:23+09:00",
    "loginUrl":      "https://new-admin.08liter.com/partner/accept-invite/<token>",
    "tokenExpiresAt":"2026-05-26T10:01:23+09:00"
  }
}
```

`result` enum:
- `SUCCESS` — deposit ≥ rewardPoints × offerCount → campaign goes live (statusCode = `1209`)
- `INSUFFICIENT_DEPOSIT` — deposit < required → campaign saved as `1206` (not visible); add deposit via Hub Pay then `activate`

`onboarding.emailSent` may be `false` when:
- SES is not configured for the environment, OR
- `contactEmail` is a placeholder domain (`@08liter.ai`, `@placeholder.08liter.ai`) — automatic skip to prevent bounce

In that case, display `loginUrl` on your post-payment page so the user can still enter.

Errors:

| HTTP | code | meaning |
|---|---|---|
| 400 | `INVALID_PAYLOAD` | Zod validation (missing required field, bad enum, bad email). `detail.issues` has the field-level list. |
| 401 | `TOKEN_*` | ServiceJWT sign/expiry/jti |
| 403 | `SERVICE_NOT_ALLOWED` | ConnectedApp 미등록 / SUSPENDED |
| 409 | `EMAIL_DUPLICATE` | `contactEmail` is already a registered manager. Payment is already done — surface this to the user and let ops decide refund vs merge. |
| 409 | `IDEMPOTENCY_CONFLICT` | Same `Idempotency-Key` used with a different body |
| 422 | `INVALID_ORDER` | `orderCode` not found / not PAID / belongs to a different service / already consumed |
| 429 | `RATE_LIMITED` | per-app rate limit. `Retry-After` header |
| 500 | `INTERNAL_ERROR` | transaction failure. (Email-only failure returns 200 + `emailSent:false` instead.) |

curl:

```bash
curl -X POST https://hub.08liter.com/v1/external/pybk/onboard \
  -H "Authorization: ServiceJWT $JWT" \
  -H "Idempotency-Key: lead-2026-05-27-0001" \
  -H "Content-Type: application/json" \
  -d @onboard-body.json
```

---

### 4-1. Partner ensure (signup-or-lookup) — legacy

```bash
curl -X POST https://hub.08liter.com/v1/partners/ensure \
  -H "Authorization: ServiceJWT $JWT" \
  -H "Idempotency-Key: lead-0001" \
  -H "Content-Type: application/json" \
  -d '{
    "partner": { "name": "주식회사 ABC", "country": "KR",
      "businessNumber": "123-45-67890", "phoneNumber": "02-1234-5678",
      "agreeYn": "Y", "salesAgree": "pybk-sales@2026-05-22" },
    "admin": { "email": "ceo@abc.com", "adminName": "홍길동" }
  }'
```

Minimum fields: 회사명 · 국가 · 담당자 이메일 · 담당자명 · 연락처. (사업자번호는 선택 — 빈칸 시 KR 은
placeholder `000-00-00000` 자동, 어드민 정정 대상.)
Auto-handled: 약관·활성화·휴대폰인증·비밀번호·로그인계정 (사업자등록증 인증 절차 없음).

```json
// 200 found  | 201 created
{ "operation": "CREATED", "partnerId": "12377", "adminId": "24568", "status": "ACTIVE" }
```
→ store `partnerId` against your member.

### 4-2. 결제(충전) — Hub Pay

`POST /v1/pay/orders` (existing Hub Pay flow). Charge `points × offerCount` (+정책). The resulting
deposit is the campaign budget and the gate for going live.

### 4-3. PYBK campaign create

```bash
curl -X POST https://hub.08liter.com/v1/campaigns/pybk \
  -H "Authorization: ServiceJWT $JWT" \
  -H "Idempotency-Key: order-0001" \
  -H "Content-Type: application/json" \
  -d '{
    "partnerId": "12377",
    "country": "KR",
    "product": { "name": "피카소 아이라이너", "price": 19900,
      "thumbnailUrl": "https://img.08liter.com/x.jpg" },
    "channel": "Coupang",
    "purchaseUrl": "https://www.coupang.com/vp/products/123",
    "offerCount": 10,
    "points": 20000,
    "startAt": "2026-06-01T00:00:00+09:00",
    "endAt":   "2026-06-30T23:59:59+09:00"
  }'
```

Business values are **your input** (server never invents them): product name·price·thumbnail,
channel·purchaseUrl, offerCount, points, dates. Optional: `reviewPrice` (리뷰가 — omitted → 10% of
`points`), `targetCode`, `targetLevelCode`, `minFollowCount` (omitted → derived). Brand/product are
auto-created (brand name = company name).

```json
// 201 (충전 충분 → 라이브)
{ "campaignId": "73254", "statusCode": "1209", "status": "IN_PROGRESS",
  "activated": true, "depositReserved": 200000, "depositAvailable": 300000, "depositShortfall": 0 }
// 201 (충전 부족 → 임시저장, 미노출)
{ "campaignId": "73254", "statusCode": "1206", "status": "TEMPORARY_SAVED",
  "activated": false, "depositReserved": 200000, "depositAvailable": 50000, "depositShortfall": 150000 }
```

### 4-4. Activate (after charging)

```bash
curl -X POST https://hub.08liter.com/v1/campaigns/73254/activate \
  -H "Authorization: ServiceJWT $JWT"
```
```json
// 200 activated  | 402 still insufficient
{ "campaignId": "73254", "activated": true, "statusCode": "1209",
  "depositRequired": 200000, "depositAvailable": 200000, "depositShortfall": 0 }
```

### 4-5. Status (poll)

```bash
curl https://hub.08liter.com/v1/campaigns/73254/status -H "Authorization: ServiceJWT $JWT"
```
```json
{ "campaignId": "73254", "status": "IN_PROGRESS", "statusCode": "1209",
  "period": { "startAt": "...", "endAt": "...", "dueAt": "..." },
  "recruit": { "offerCount": 10, "appliedCount": 23, "byState": { "APPLY": 13, "SELECT": 10 } },
  "progress": { "reviewCount": 7, "byReviewStatus": { "1801": 6 } },
  "updatedAt": "..." }
```
Poll every 5–15 min while running. `byState`/`byReviewStatus` are raw status-code distributions
(actual reviewer apply/review happens in the 08L user app).

---

### 4-6. 🟢 Progress — list & detail (**recommended** for dashboards)

A friendlier surface than 4-5: a paginated **list** of every campaign you onboarded, and an
enriched per-campaign **detail**, both with a human-readable progress funnel (no raw-code mapping
on your side). **This §4-6 is the live, self-contained reference for the progress query API.**

```bash
# list (newest first). optional: status filter
GET https://hub.08liter.com/v1/external/pybk/campaigns?page=1&page_size=25&status=IN_PROGRESS
Authorization: ServiceJWT <token>

# detail (friendly funnel + raw code distributions)
GET https://hub.08liter.com/v1/external/pybk/campaigns/{campaignId}
Authorization: ServiceJWT <token>
```

**List query params**

| param | type | default | notes |
|---|---|---|---|
| `page` | int | `1` | 1-based |
| `page_size` | int | `25` | 1–100 (clamped to 100) |
| `status` | enum | (none) | one of the status enum values below; unknown → 400 `INVALID_STATUS` |

Sort: newest first (campaignId desc). **Ownership**: only campaigns onboarded by *your* ServiceJWT `iss` are returned (others → 403 `CAMPAIGN_NOT_OWNED`).

list response:

```json
{
  "campaigns": [
    {
      "campaignId": "73254",
      "name": "피카소 젤 아이라이너",
      "channel": "Coupang",
      "thumbnailUrl": "https://img.08liter.com/...",
      "productPrice": 19900,
      "rewardPoints": 20600,
      "purchaseUrl": "https://www.coupang.com/vp/products/12345",
      "status": "IN_PROGRESS",
      "statusLabel": "진행중",
      "statusCode": "1209",
      "period": { "startAt": "...", "endAt": "...", "dueAt": "..." },
      "externalRef": "HUBPAY-20260525-XXXX",
      "progress": {
        "offerCount": 10, "applied": 23, "underSelection": 3,
        "selected": 10, "notSelected": 10,
        "reviewSubmitted": 7, "reviewPending": 1, "reviewApproved": 6, "reviewRejected": 0,
        "reviewDraft": 2
      },
      "createdAt": "..."
    }
  ],
  "pagination": { "page": 1, "pageSize": 25, "total": 42, "totalPages": 2 }
}
```

detail = a single `campaigns[]` item **plus**:

```json
{
  "...": "(all list-item fields)",
  "raw": { "byApplyState": { "0801": 3, "0802": 10 }, "byReviewStatus": { "1214": 6, "1218": 1 } },
  "updatedAt": "..."
}
```

**`progress` funnel fields**

| field | meaning | source |
|---|---|---|
| `offerCount` | recruit target | campaign.offer_count |
| `applied` | total applications | campaign_apply (use_yn='Y') |
| `underSelection` | under selection | apply 0801 |
| `selected` | selected | apply 0802 + 0804 |
| `notSelected` | not selected | apply 0803 |
| `reviewSubmitted` | submitted (excl. draft) | review 1218+1214+1219 |
| `reviewPending` | review pending | review 1218 |
| `reviewApproved` | **approved (= real completions)** | review 1214 |
| `reviewRejected` | rejected | review 1219 |
| `reviewDraft` | draft (not submitted) | review 1213 |

Suggested progress: recruit = `selected / offerCount`, reviews = `reviewApproved / offerCount`. `externalRef` = your `orderCode` (map back to your lead).

`raw.byApplyState`: `0801` under-selection · `0802` selected · `0803` not-selected · `0804` selected-by-purchase.
`raw.byReviewStatus`: `1213` draft · `1214` approved · `1215` deleted · `1218` pending · `1219` rejected. (`raw` is a safety net; `progress` is usually enough.)

**`status` enum ← `statusCode`** (use any value as the list `status` filter)

| status | statusCode | label | meaning |
|---|---|---|---|
| `TEMPORARY_SAVED` | 1206 | 임시저장 | not visible (e.g. insufficient deposit) |
| `UNDER_REVIEW` | 1207 | 심사중 | |
| `READY` | 1208 | 준비완료 | |
| `IN_PROGRESS` | 1209 / 1213–1216 | 진행중 | live (1213–1216 = recruit/review/judge/settle phases) |
| `REVISION_REQUESTED` | 1211 | 수정요청 | |
| `COMPLETED` | 1212 | 완료 | |
| `REJECTED` | 1210 / 1301 | 반려 | |

**Errors**: `400 INVALID_STATUS` / `INVALID_ID` · `401` token (malformed/invalid/expired) · `403 SERVICE_NOT_ALLOWED` / `CAMPAIGN_NOT_OWNED` · `404 CAMPAIGN_NOT_FOUND` · `429 RATE_LIMITED`.

**Polling**: poll `IN_PROGRESS` campaigns every 5–15 min (no webhook — GET only). Numbers are point-in-time snapshots; actual apply/review happens in the 08L user app.

- Legacy 4-5 (`/v1/campaigns/{id}/status`) still works and now also returns `funnel` + `statusLabel`.

---

## 5. Operating contract

1. **Don't reuse identifiers** (Idempotency-Key / externalRef): never reassign a withdrawn user's id.
2. **Cache partnerId** from ensure.
3. **Charge → create/activate**: a campaign only goes live with deposit ≥ budget.
4. 08L records your `iss` as the source of every signup/campaign — no extra work on your side.
