# Solve Developer Center
URL: https://developers.solve.im/docs
> 쏠브와 외부 시스템을 연동하는 개발자를 위한 문서입니다.
쏠브와 외부 시스템을 연동하는 개발자를 위한 문서입니다.
# AI로 구현하기
URL: https://developers.solve.im/docs/partner-integration/ai-guide
> Claude·ChatGPT·Cursor 같은 AI 코딩 도구에 붙여넣으면 규약대로 구현하도록 만든 통합 프롬프트와 AI용 문서 경로입니다.
AI 코딩 도구로 연동을 구현한다면 이 페이지부터 쓰세요. 아래 **통합 프롬프트** 하나에 연동 규약, 틀리기 쉬운 점, 완료 기준을 모두 담았습니다. 문서를 일일이 읽히지 않아도 AI 가 같은 기준으로 구현합니다.
## 사용 방법
### 프롬프트 복사
아래 코드 블록 오른쪽 위의 복사 버튼을 누릅니다.
### 맨 위 "우리 환경" 채우기
언어·프레임워크, 로그인 방식(ⓐ/ⓑ), 회원·구매 테이블 이름을 적습니다. 모르는 칸은 비워 두면 AI 가 먼저 물어봅니다.
### AI 도구에 붙여넣기
Claude Code·Cursor·Codex 처럼 저장소를 읽는 도구라면 저장소 루트에서 실행하세요. AI 가 기존 회원·주문 코드를 찾아 거기에 맞춰 구현합니다.
## 통합 프롬프트
```text title="solve-integration-prompt.txt"
# 과제: 쏠브(Solve) 제휴사 연동 구현
## 우리 환경 (채워서 사용)
- 언어/프레임워크:
- 로그인 방식: ⓐ 자격증명 API / ⓑ 호스팅 로그인 페이지 (소셜 로그인이 하나라도 있으면 ⓑ)
- 회원 테이블과 불변 회원번호 컬럼:
- 구매(주문) 테이블과 "현재 이용 가능" 판정 기준(환불·만료 제외):
## 배경
쏠브는 전자책 학습 앱이다. 우리 사이트에서 책을 산 회원이 쏠브 앱에서 우리 계정으로 로그인하면,
쏠브 서버가 우리 API 를 호출해 구매 목록을 받아 책장에 책을 넣는다.
**쏠브가 호출하는 쪽이고, 우리가 API 를 구현하는 쪽**이다.
전체 규약 원문: https://developers.solve.im/llms-full.txt (필요하면 읽어라)
## 구현할 것
1. 로그인 (둘 중 하나)
ⓐ 자격증명 로그인 API — POST, JSON 요청 {"id": string, "password": string}
- 성공: 200 {"user_key": string}
- 아이디·비밀번호 불일치: 4xx (쏠브 앱이 "비밀번호 불일치"로 안내, 재시도 안 함)
- 서버 오류: 5xx (쏠브가 최대 2번 재시도)
ⓑ 호스팅 로그인 페이지 — 쏠브 앱 웹뷰가 우리 "쏠브 전용 로그인 URL"을 쿼리 파라미터 없이 연다
- 회원이 로그인에 성공하면 우리 **서버에서** 쏠브 콜백 호출:
POST {SOLVE_CALLBACK_URL} JSON {"id": string, "platform_type": number}
헤더 Authorization: Bearer {SOLVE_CALLBACK_SECRET} (쏠브 발급, 401 이면 재시도 금지)
- 응답 200 {"redirect_url": string} → 같은 웹뷰에서 그 URL 로 302 이동 (새 창·외부 브라우저 금지, URL 수정 금지 — 서명 포함)
- 소셜 로그인으로 외부에 다녀오는 경우 세션에 "쏠브 연동 중" 표시를 유지했다가 완료 시 콜백
- SOLVE_CALLBACK_URL, SOLVE_PLATFORM_TYPE, SOLVE_CALLBACK_SECRET 은 쏠브가 전달하는 값 → 환경변수로
2. 구매내역 API — POST, JSON 요청 {"user_key": string}
- 200 {"item_list": [{"item_code": string, "item_name": string(선택)}]}
- 선택 필드 (v1.1, 없어도 된다 — v1.0 대로 구현한 응답은 수정 없이 유효): 항목마다 price · paid_at · valid_from · valid_until 을 더 담을 수 있다
· price: 0 이상 정수(숫자) 또는 숫자만으로 된 문자열(1~12자리), 원 단위
· paid_at: ISO 8601. 오프셋(Z, +09:00)이 있으면 그대로, 없으면 한국 시간(+09:00), 날짜만이면 그날 00:00 한국 시간
· valid_from, valid_until: 1~32자 문자열 (쏠브는 해석하지 않고 그대로 기록)
· 형식이 틀리면 그 필드만 무시한다 (책 지급에는 영향 없음)
· price 를 안 보내거나 0 이면 파트너 어드민에 등록한 가격으로 정산한다
· valid_until 이 지나도 책은 자동으로 빠지지 않는다 — 회수는 목록에서 빼는 것뿐이다
- **현재 이용 가능한 구매만** 담는다. 환불·만료는 뺀다
- 구매가 없거나 회원이 없으면 200 {"item_list": []}
- 조회 실패·장애는 반드시 5xx. 예외를 삼키고 200 을 보내지 마라 (일부만 조회된 목록이면 빠진 책이 회수된다)
- 페이지네이션 없이 한 번에 전부
3. 공통
- HTTPS, Content-Type: application/json, 10초 안에 응답 (쏠브 타임아웃 10초)
- 쏠브는 우리가 발급한 시크릿을 헤더에 담아 보낸다. 헤더 이름은 우리가 정한다 (기본값: X-Solve-Secret)
- 시크릿은 환경변수로 두고(비어 있으면 서버 시작 실패) 상수 시간 비교로 확인, 불일치면 `401` 또는 `403`
- 쏠브는 `401` 과 `403` 을 구분하지 않고 같은 인증 실패로 처리한다 (둘 다 재시도 없음)
- 같은 요청이 재시도로 여러 번 와도 데이터가 바뀌면 안 된다 (조회 전용)
- 응답 본문은 1MB(1,048,576바이트) 이하. 쏠브는 1MB 를 넘는 응답을 받지 않는다 (넘으면 그 호출은 실패, 재시도 없음 — 구매내역이면 기존 책장 유지). 구매내역·ⓐ 로그인 모두 해당, 필요한 필드만 담는다
## 반드시 지킬 것 (틀리기 쉬움)
- user_key(ⓑ 의 id)는 **절대 바뀌지 않는 내부 회원번호**. 로그인 아이디·이메일·소셜 계정 ID 금지
- 탈퇴 회원의 user_key 를 다른 회원에게 재사용 금지
- item_code 는 상품 단위 코드이며, 쏠브 파트너 어드민에 입력한 "연동코드"와 같아야 한다. 쏠브는 item_code 의 앞뒤 공백을 떼고 비교하고, 그 밖은 대소문자까지 글자 그대로 같아야 한다 (연동코드 앞뒤에 공백을 넣지 않는다)
- 비밀번호·시크릿을 로그에 남기지 않는다
- 예외를 삼키고 200 을 보내지 않는다
## 결정 기본값 (모호하면 이렇게)
- 엔드포인트 경로: POST /solve/login (ⓐ), POST /solve/purchases
- 아이디·비밀번호 불일치 401, 시크릿 불일치 `401` 또는 `403` (기본은 403), 요청 형식 오류 400, 그 외 예외 500
- 에러 응답 본문: {"error": ""}
## 산출물
1. 위 API 구현 코드 (기존 회원·주문 코드를 찾아서 재사용)
2. 테스트: 구매 회원 200, 없는 회원 200+빈 배열, 환불 상품 제외, 틀린 시크릿 `401` 또는 `403`, DB 오류 500
(ⓐ 는 맞는/틀린 비밀번호도)
3. 쏠브가 보낼 요청을 재현하는 curl 명령
4. 쏠브에 전달할 값 목록: 각 URL, 시크릿 헤더 이름, 테스트 계정
5. 구현 뒤 `solve-selfcheck.mjs`(https://developers.solve.im/tools/solve-selfcheck.mjs)로 확인한다 — `--init` 으로 설정 틀을 만들고 값을 채워 실행하며, FAIL 이 0개일 때까지 고친다 (ⓑ 는 `solve-callback-mock.mjs` 로 콜백을 시험)
작업 전에 우리 코드베이스에서 회원·구매 관련 코드를 먼저 찾아보고, 구현 계획을 짧게 보여준 뒤 진행해라.
```
## 저장소에 규칙으로 넣기
같은 저장소에서 AI 에게 여러 번 일을 시킨다면, 프롬프트를 규칙 파일로 넣어 두면 매번 붙여넣지 않아도 됩니다.
| 도구 | 파일 위치 |
| -------------- | ------------------------------------- |
| Claude Code | 저장소 루트 `CLAUDE.md` 에 추가 |
| Codex, 기타 에이전트 | 저장소 루트 `AGENTS.md` 에 추가 |
| Cursor | `.cursor/rules/solve-integration.mdc` |
| GitHub Copilot | `.github/copilot-instructions.md` |
## AI 도구용 문서 경로
| 경로 | 내용 | 쓰는 곳 |
| ------------------------------------------------------------- | ------------- | --------------------------------------------------------------------- |
| [`/llms.txt`](https://developers.solve.im/llms.txt) | 전체 문서 목차 | AI 에게 "여기서 필요한 문서를 찾아 읽어" |
| [`/llms-full.txt`](https://developers.solve.im/llms-full.txt) | 전체 문서를 한 파일로 | 한 번에 모든 규약을 넘길 때 |
| 각 페이지 URL 뒤에 `.md` | 그 페이지만 마크다운으로 | 특정 페이지만 넘길 때 (예: `/docs/partner-integration/purchase-history-api.md`) |
각 페이지 제목 아래의 **마크다운 복사**·**AI로 열기** 버튼으로도 그 페이지를 바로 넘길 수 있습니다.
특히 "예외를 삼키고 200 응답"과 "로그인 아이디를 user\_key 로 사용"은 AI 가 자주 만드는 실수입니다. [테스트와 출시](https://developers.solve.im/docs/partner-integration/onboarding)의 셀프 점검 도구(`solve-selfcheck.mjs`)로 확인한 뒤 쏠브에 전달해 주세요.
# 변경 이력
URL: https://developers.solve.im/docs/partner-integration/changelog
> 제휴사 연동 API 버전 이력
# 변경 이력
## 2026-10-03 — 응답 크기 상한 명시
`contractVersion` 은 그대로입니다. 구매내역 API 와 ⓐ 자격증명 로그인 API 의 응답 본문 크기 상한을 문서에 적었습니다.
* **응답은 1MB 이하**: 응답 본문은 1MB(1,048,576바이트) 이하여야 합니다. 쏠브는 1MB 를 넘는 응답을 받지 않으며, 넘으면 그 호출은 실패로 처리하고 재시도하지 않습니다. 구매내역은 기존 책장을 유지합니다. ([구매내역 API](https://developers.solve.im/docs/partner-integration/purchase-history-api), [ⓐ 자격증명 로그인 API](https://developers.solve.im/docs/partner-integration/login-credential-api))
* **`item_code` 비교 설명 정정**: 쏠브는 `item_code` 의 앞뒤 공백을 떼고 비교합니다. 그 밖은 대소문자까지 글자 그대로 같아야 합니다. ([상품 연결](https://developers.solve.im/docs/partner-integration/product-catalog))
## 2026-10-02 — 점검 도구 추가
규약은 바뀌지 않았습니다. 제휴사가 자기 PC 에서 돌리는 점검 도구를 추가했습니다.
* **셀프 점검 v2 (`solve-selfcheck.mjs`)**: 구매내역 API 와 ⓐ 로그인 API 를 한 번에 점검합니다. 응답 형식, 선택 필드, 오류 응답, 응답 시간을 확인합니다. 기존 bash 스크립트는 "빠른 확인"으로 그대로 남아 있습니다. ([테스트와 출시](https://developers.solve.im/docs/partner-integration/onboarding))
* **콜백 흉내 도구 (`solve-callback-mock.mjs`)**: ⓑ 방식 제휴사가 계약 전에도 자기 로그인 페이지의 콜백을 시험할 수 있습니다. ([ⓑ 호스팅 로그인 페이지](https://developers.solve.im/docs/partner-integration/login-hosted-page))
## v1.1.0 — 2026-10-02
v1.0.0 과 호환됩니다. **기존 구현은 수정 없이 동작합니다.**
* **구매내역 선택 필드 4개**: 항목마다 `price`·`paid_at`·`valid_from`·`valid_until` 을 선택으로 더 담을 수 있습니다. 없어도 동작하고, 형식이 틀리면 그 필드만 무시하며 책 지급에는 영향이 없습니다. ([구매내역 API](https://developers.solve.im/docs/partner-integration/purchase-history-api))
* **`price` 정산 규칙**: `price` 를 안 보내거나 `0` 이면 파트너 어드민에 등록한 가격으로 정산합니다.
* **인증 실패 상태 코드 표기 통일**: 시크릿 불일치는 `401` 또는 `403` 으로 표기합니다. 쏠브는 둘을 구분하지 않고 같은 인증 실패로 처리합니다(둘 다 재시도하지 않습니다). 예시 코드는 그대로 유효합니다. ([구매내역 API](https://developers.solve.im/docs/partner-integration/purchase-history-api), [ⓐ 자격증명 로그인 API](https://developers.solve.im/docs/partner-integration/login-credential-api))
* **정정 — 시크릿 회전**: 제휴사가 발급한 API 시크릿은 제휴사가 새 값과 옛 값을 함께 받아 주는 동안 쏠브가 새 값으로 바꿉니다(반영까지 최대 5분). 쏠브가 발급한 콜백 인증 시크릿은 쏠브가 일정 기간 새 값과 옛 값을 함께 받습니다. ([보안 요구사항](https://developers.solve.im/docs/partner-integration/security))
* **정정 — 롤백**: 연동을 끄면 신규 연동과 동기화가 멈추고, 이미 들어간 책은 남습니다. ([테스트와 출시](https://developers.solve.im/docs/partner-integration/onboarding))
* **기존 전자책 연동코드**: 이미 등록한 전자책은 파트너 어드민 상품 상세에서 연동코드를 한 번 넣을 수 있습니다. ([상품 연결](https://developers.solve.im/docs/partner-integration/product-catalog))
* **정정 — 구매 후 사용자 안내**: 앱의 연동 화면으로 바로 들어가는 링크는 제공하지 않습니다. 대신 쏠브북스 웹의 제휴사 연동 화면 주소를 안내합니다. ([구매 후 사용자 안내](https://developers.solve.im/docs/partner-integration/user-discovery))
## 2026-09-30 — ⓑ 콜백 인증 추가
* **ⓑ 콜백 인증**: 쏠브가 제휴사별로 발급한 시크릿을 `Authorization: Bearer` 헤더로 보내야 합니다. **신규 제휴사는 처음부터 필수**이며, 이미 운영 중인 제휴사는 쏠브와 일정을 맞춰 전환합니다(전환 전까지는 기존처럼 동작). 인증 실패는 `401` 입니다. ([ⓑ 호스팅 로그인 페이지](https://developers.solve.im/docs/partner-integration/login-hosted-page))
* **`redirect_url` 서명**: 쏠브가 `redirect_url` 에 위변조 방지 서명 파라미터를 추가합니다. 제휴사는 지금처럼 URL 을 수정 없이 그대로 사용하면 됩니다.
## 2026-09-30 — 문서 개편
API 계약 변경은 없습니다. 읽기 쉽게 구조를 바꾸고 예시를 늘렸습니다.
* **새 페이지**: [구현 순서](https://developers.solve.im/docs/partner-integration/quickstart), [AI로 구현하기](https://developers.solve.im/docs/partner-integration/ai-guide)(통합 프롬프트), [자주 묻는 질문](https://developers.solve.im/docs/partner-integration/faq)
* **구현 예시**: 로그인·구매내역 API 페이지에 Node.js·Python·Java·PHP 예시와 curl 재현 명령을 추가했습니다.
* **셀프 점검**: [테스트와 출시](https://developers.solve.im/docs/partner-integration/onboarding)에 구매내역 API 점검 스크립트를 추가했습니다.
* **호출 조건 명시**: 호출당 타임아웃 10초, `5xx`·타임아웃 시 최대 2번 재시도, `4xx` 는 재시도하지 않음.
* **상황별 응답 표**: 구매내역 API 의 응답별 쏠브 처리를 한 표로 정리했습니다.
* **정정 — 빈 목록 응답**: 07-21 에 "빈 `item_list`(200)면 권한을 회수한다"고 적었으나, 실제로는 목록이 **통째로 비면 회수하지 않습니다**(장애 오탐 방지). 일부 항목이 빠진 경우에만 그 항목을 회수합니다. [구매내역 API](https://developers.solve.im/docs/partner-integration/purchase-history-api)의 상황별 응답 표를 확인해 주세요.
* **로그인 방식 기준**: 자체 ID/PW 와 소셜 로그인을 함께 운영하면 ⓑ 를 선택하도록 명시했습니다.
* **상품 연결**: 파트너 어드민에 전자책을 등록하며 연동코드를 입력하면 카탈로그 파일이 필요 없음을 명시하고, 판매유형(`연동형`·`연동+쏠브북스`)과 패키지 입력 방법을 정리했습니다.
* AI 도구용 경로(`/llms.txt`, `/llms-full.txt`, 페이지 URL + `.md`)와 페이지별 "마크다운 복사"·"AI로 열기" 버튼을 제공합니다.
## 2026-07-21 — 문서 정정
쏠브의 실제 동작에 맞게 서술을 바로잡았습니다. API 동작 자체가 바뀐 것은 아니지만, 아래 항목은 구현에 영향을 줄 수 있으니 확인해 주세요.
* **`user_key` 미존재 응답** — 회원이 더 이상 존재하지 않으면 빈 `item_list`(200)로 응답해야 권한이 회수됩니다. `404` 같은 오류 응답은 일시 장애로 간주해 기존 권한을 유지합니다. ([구매내역 API](https://developers.solve.im/docs/partner-integration/purchase-history-api))
* **시크릿 발급 주체** — server-to-server 시크릿은 제휴사가 발급하고, 쏠브가 헤더에 담아 보냅니다.
* **ⓑ 콜백 `id`** — 소셜 인증을 쓰더라도 소셜 프로필 ID·이메일이 아니라 제휴사 내부 회원의 불변 식별자를 보내야 합니다.
* **ⓑ 리다이렉트** — 연동을 시작한 브라우저(쏠브 앱이 연 웹뷰) 안에서 `redirect_url` 로 이동해야 연동이 완료됩니다. ([보안](https://developers.solve.im/docs/partner-integration/security))
* **동기화 시점** — 연동 직후 1회 자동으로 동기화합니다.
* **상품 카탈로그** — 파트너 어드민 등록 시 자동 매핑되는 방식과 `item_code` 재사용 금지 규칙을 추가했습니다.
* [구매 후 사용자 안내](https://developers.solve.im/docs/partner-integration/user-discovery) 페이지를 새로 만들었습니다.
## 2026-05-29 — 문서 보강
* `item_code` 가 책 단위가 아닌 **상품 단위**임을 명시하고, 패키지·혼합 세트 규칙과 권장 필드 `isbn_list` 를 추가했습니다. ([상품 카탈로그](https://developers.solve.im/docs/partner-integration/product-catalog))
## v1.0.0 — 2026-05-19
표준 API 를 처음 공개했습니다.
## 변경 정책
* **호환되지 않는 변경**은 라이브 제휴사에 미리 안내하고, 버전을 올려서 적용합니다.
* **호환되는 변경**은 필드 추가처럼 하위 호환 범위 안에서만 진행합니다.
* 정식 deprecation 정책은 실제로 변경이 생길 때 이 페이지에 추가합니다.
# 자주 묻는 질문
URL: https://developers.solve.im/docs/partner-integration/faq
> 제휴사 개발자와 담당자가 자주 묻는 질문을 모았습니다.
아니요. 쏠브가 제휴사 API 를 호출합니다. 제휴사가 쏠브를 호출하는 일은 [ⓑ 호스팅 로그인 페이지](https://developers.solve.im/docs/partner-integration/login-hosted-page) 방식의 콜백 1건뿐입니다.
아니요. 쏠브가 연동 직후, 회원의 책장 진입, "전체 동기화" 때 구매내역 API 를 호출해 최신 상태를 가져옵니다.
ⓑ 를 쓰세요. 카카오 등 소셜로 가입한 회원은 ID/PW 가 없어서 ⓐ 로는 연동할 수 없습니다. 기준은 [개념과 용어](https://developers.solve.im/docs/partner-integration/overview)에 있습니다.
권장하지 않습니다. 아이디는 바뀔 수 있고, 바뀌면 회원이 연동과 책장의 책을 잃습니다. 내부 회원번호처럼 절대 바뀌지 않는 값을 쓰세요.
아니요. **`200` + 빈 `item_list`** 로 응답해 주세요. `404` 등 오류는 쏠브가 장애로 기록합니다. 자세한 표는 [구매내역 API](https://developers.solve.im/docs/partner-integration/purchase-history-api)에 있습니다.
구매내역 API 응답에서 그 상품을 빼면, 회원의 다음 동기화(책장 진입 등) 때 빠집니다. 실시간으로 빠지지는 않습니다. 단, 회원의 상품이 **전부** 환불돼 목록이 통째로 비면 쏠브는 장애와 구분할 수 없어 책을 빼지 않습니다. 이 경우는 쏠브 담당자와 협의해 주세요.
쏠브 파트너 어드민에 전자책을 직접 등록하면서 연동코드를 입력한다면 필요 없습니다. 쏠브가 전자책을 대신 등록하는 경우에만 보내주세요. [상품 연결](https://developers.solve.im/docs/partner-integration/product-catalog)을 참고하세요.
연동코드를 입력해 등록하면, 그전에 구매한 회원도 다음 동기화 때 책장에 책이 들어갑니다.
한 번에 모두 돌려주세요. 분할이 꼭 필요하면 미리 협의해 주세요.
회원 행동(책장 진입 등)에 따라 호출하므로 특정 시간에 몰릴 수 있습니다. rate limit 은 두지 않아도 되고, 둔다면 분당 600회 이상으로 잡아 주세요.
공개 샌드박스는 없습니다. 대신 제휴사 PC 에서 돌리는 점검 도구 두 가지가 있습니다. [테스트와 출시](https://developers.solve.im/docs/partner-integration/onboarding)의 셀프 점검 도구(`solve-selfcheck.mjs`)는 구매내역 API 와 ⓐ 로그인 API 가 규약대로 응답하는지 확인하고, ⓑ 방식은 [콜백 흉내 도구](https://developers.solve.im/docs/partner-integration/login-hosted-page)(`solve-callback-mock.mjs`)로 계약 전에도 자기 로그인 페이지의 콜백을 시험할 수 있습니다. 먼저 확인한 뒤 테스트 계정을 보내주시면 쏠브가 실제 앱으로 검증합니다.
찾는 답이 없으면 쏠브 담당자에게 메일로 문의해 주세요.
# 제휴사 연동 개요
URL: https://developers.solve.im/docs/partner-integration
> 제휴사 사이트에서 구매한 책·강의를 쏠브 앱에서 그대로 이용하게 하는 연동입니다.
제휴사 사이트에서 책이나 강의를 구매한 회원이 쏠브 앱에서 **제휴사 계정으로 한 번 로그인**하면, 구매한 콘텐츠가 쏠브 책장에 자동으로 들어옵니다. 이 문서는 그 연동을 제휴사 개발자가 직접 구현할 수 있도록 정리한 가이드입니다.
## 3줄 요약
1. 제휴사는 **API 2개**(로그인, 구매내역)를 만듭니다. 쏠브가 이 API 를 호출합니다.
2. 전자책을 쏠브 파트너 어드민에 등록할 때 **연동코드**(제휴사 상품코드)를 입력합니다. 어드민을 쓰지 않으면 상품 목록 파일로 대신합니다.
3. 쏠브가 테스트 계정으로 앱 화면까지 확인한 뒤 출시합니다.
## 누가 무엇을 하나요?
쏠브가 **호출하는 쪽**, 제휴사가 **구현하는 쪽**입니다. 제휴사가 쏠브 API 를 호출하는 일은 ⓑ 방식의 콜백 1건뿐입니다.
| 구분 | 제휴사 | 쏠브 |
| ----- | ------------------------------------------ | ----------------------------- |
| 로그인 | 로그인 API(ⓐ) 또는 로그인 페이지(ⓑ)를 구현 | 앱에서 로그인 화면을 띄우고 `user_key` 저장 |
| 구매내역 | `user_key` 를 받아 현재 유효한 상품코드 목록을 반환 | 필요할 때 호출해 책장 권한을 부여·회수 |
| 상품 연결 | 파트너 어드민에 전자책 등록 + 연동코드 입력 (또는 상품 목록 파일 전달) | 연동코드로 상품을 매칭 |
| 테스트 | 테스트 계정과 URL 전달 | 실제 호출과 앱 화면으로 검증 |
## 한눈에 보기
회원의 로그인 방식에 따라 두 가지가 있습니다. 소셜 로그인(카카오·네이버·구글 등)을 함께 운영하면 **로그인 페이지 방식**입니다.
## 전체 흐름 (시퀀스)
## 어디서부터 볼까요?
# ⓐ 자격증명 로그인 API
URL: https://developers.solve.im/docs/partner-integration/login-credential-api
> 쏠브가 회원의 ID/PW 를 보내면 확인하고 user_key 를 돌려주는, 제휴사가 구현하는 API 입니다.
회원이 쏠브 앱에 입력한 ID/PW 를 쏠브 서버가 그대로 보내면, 제휴사가 확인한 뒤 `user_key` 를 돌려주는 server-to-server API 입니다. 자체 ID/PW 로그인만 운영하는 제휴사에 권장합니다. 소셜 로그인을 함께 운영한다면 [ⓑ 호스팅 로그인 페이지](https://developers.solve.im/docs/partner-integration/login-hosted-page)를 쓰세요.
## 한눈에 보기
| 항목 | 내용 |
| ------- | ------------------------------------------------- |
| 호출하는 쪽 | 쏠브 서버 → 제휴사 서버 |
| 메서드·URL | `POST`, URL 은 제휴사가 정합니다 |
| 형식 | `Content-Type: application/json` 요청, JSON 응답 |
| 인증 | 제휴사가 발급한 시크릿을 쏠브가 헤더에 담습니다. 헤더 이름과 방식은 제휴사가 정합니다. |
| 타임아웃 | 호출당 10초 |
| 재시도 | `5xx`·타임아웃이면 최대 2번 더 호출합니다. `4xx` 는 재시도하지 않습니다. |
## 요청
쏠브가 보내는 요청은 아래와 같습니다. 헤더 이름 `X-Solve-Secret` 은 예시입니다.
```bash title="쏠브가 보내는 요청 (curl 로 재현)"
curl -X POST https://api.partner.example.com/solve/login \
-H 'Content-Type: application/json' \
-H 'X-Solve-Secret: <제휴사가 발급한 시크릿>' \
-d '{"id": "partner_user_01", "password": "p@ssw0rd"}'
```
| 필드 | 타입 | 필수 | 설명 |
| ---------- | ------ | -- | --------------- |
| `id` | string | 필수 | 회원이 입력한 로그인 아이디 |
| `password` | string | 필수 | 회원이 입력한 비밀번호 |
## 응답
### 성공 (200)
```json
{ "user_key": "100023" }
```
| 필드 | 타입 | 필수 | 설명 |
| ---------- | ------ | -- | --------------------------------------------------------------- |
| `user_key` | string | 필수 | 회원의 불변·유일 식별자, 최대 255자. 내부 회원번호를 권장합니다. 다른 필드를 더 담아도 쏠브는 무시합니다. |
**응답 본문은 1MB(1,048,576바이트) 이하여야 합니다.** 쏠브는 1MB 를 넘는 응답을 받지 않습니다. 넘으면 그 로그인 호출은 실패로 처리하고 재시도하지 않습니다. 압축해서 보내는 경우는 푼 뒤의 크기로 셉니다.
### 실패
쏠브는 **상태 코드로 인증 실패와 시스템 오류를 구분**해 회원에게 다른 안내를 보여줍니다. 본문 형식은 자유입니다.
| 상황 | 상태 코드 | 쏠브 앱에서 보이는 안내 | 재시도 |
| ------------ | -------------- | ------------------------ | ----- |
| 아이디·비밀번호 불일치 | `401` 등 `4xx` | "아이디 또는 비밀번호가 일치하지 않습니다" | 안 함 |
| 시크릿 불일치 | `401` 또는 `403` | 위와 같음 | 안 함 |
| 요청 형식 오류 | `400` 등 `4xx` | 위와 같음 | 안 함 |
| 서버 오류, DB 장애 | `500` 등 `5xx` | 일반 오류 안내 | 최대 2번 |
쏠브는 `401` 과 `403` 을 구분하지 않고 같은 인증 실패로 처리합니다. 둘 다 재시도하지 않습니다.
장애 상황을 `4xx` 로 응답하면 회원은 비밀번호가 틀린 줄 알고 계속 다시 입력합니다. 예외가 나면 반드시 `5xx` 로 응답해 주세요.
## 구현 예시
시크릿 확인 → 입력값 확인 → 회원 인증 → `user_key` 응답 순서입니다. 회원 조회·비밀번호 검증 함수는 제휴사 시스템에 맞게 바꾸세요.
Node.js
Python
Java
PHP
```js
import express from 'express';
import crypto from 'node:crypto';
const app = express();
app.use(express.json());
// 제휴사가 발급해 쏠브에 전달한 시크릿
const SOLVE_SECRET = Buffer.from(process.env.SOLVE_SECRET ?? '');
// 시크릿이 비어 있으면 빈 헤더 요청이 통과하므로 서버를 띄우지 않는다
if (SOLVE_SECRET.length === 0) throw new Error('SOLVE_SECRET is not set');
function isFromSolve(req) {
const given = Buffer.from(req.get('X-Solve-Secret') ?? '');
return given.length === SOLVE_SECRET.length && crypto.timingSafeEqual(given, SOLVE_SECRET);
}
app.post('/solve/login', async (req, res) => {
if (!isFromSolve(req)) return res.status(403).json({ error: 'forbidden' });
const { id, password } = req.body ?? {};
if (typeof id !== 'string' || typeof password !== 'string') {
return res.status(400).json({ error: 'invalid_request' });
}
try {
const member = await findMemberByLoginId(id);
if (!member || !(await verifyPassword(password, member.passwordHash))) {
return res.status(401).json({ error: 'invalid_credentials' });
}
// 로그인 아이디가 아니라 절대 바뀌지 않는 내부 회원번호
return res.json({ user_key: String(member.memberNo) });
} catch (err) {
console.error('solve login failed', err);
return res.status(500).json({ error: 'internal_error' });
}
});
```
```python
import hmac
import logging
import os
from fastapi import FastAPI, Header
from fastapi.responses import JSONResponse
from pydantic import BaseModel
app = FastAPI()
logger = logging.getLogger(__name__)
SOLVE_SECRET = os.environ["SOLVE_SECRET"] # 제휴사가 발급해 쏠브에 전달한 시크릿
class LoginRequest(BaseModel):
id: str
password: str
@app.post("/solve/login")
def login(body: LoginRequest, x_solve_secret: str = Header(default="")):
if not hmac.compare_digest(x_solve_secret, SOLVE_SECRET):
return JSONResponse({"error": "forbidden"}, status_code=403)
try:
member = find_member_by_login_id(body.id)
if member is None or not verify_password(body.password, member.password_hash):
return JSONResponse({"error": "invalid_credentials"}, status_code=401)
# 로그인 아이디가 아니라 절대 바뀌지 않는 내부 회원번호
return {"user_key": str(member.member_no)}
except Exception:
logger.exception("solve login failed")
return JSONResponse({"error": "internal_error"}, status_code=500)
```
```java
@RestController
@RequestMapping("/solve")
public class SolveLoginController {
private static final Logger log = LoggerFactory.getLogger(SolveLoginController.class);
@Value("${solve.secret}") // 제휴사가 발급해 쏠브에 전달한 시크릿
private String solveSecret;
private final MemberService memberService;
public SolveLoginController(MemberService memberService) {
this.memberService = memberService;
}
public record LoginRequest(String id, String password) {}
@PostMapping("/login")
public ResponseEntity
```php
'forbidden']);
exit;
}
$body = json_decode(file_get_contents('php://input'), true);
$id = $body['id'] ?? null;
$password = $body['password'] ?? null;
if (!is_string($id) || !is_string($password)) {
http_response_code(400);
echo json_encode(['error' => 'invalid_request']);
exit;
}
try {
$member = find_member_by_login_id($id);
if (!$member || !password_verify($password, $member['password_hash'])) {
http_response_code(401);
echo json_encode(['error' => 'invalid_credentials']);
exit;
}
// 로그인 아이디가 아니라 절대 바뀌지 않는 내부 회원번호
echo json_encode(['user_key' => (string) $member['member_no']]);
} catch (Throwable $e) {
error_log('solve login failed: ' . $e->getMessage());
http_response_code(500);
echo json_encode(['error' => 'internal_error']);
}
```
## 흐름
## 구현 체크리스트
* [ ] 로그인에 성공하면 내부 회원번호처럼 **바뀌지 않는 값**을 `user_key` 로 돌려준다
* [ ] 비밀번호 불일치는 `4xx`, 서버 오류는 `5xx` 로 응답한다
* [ ] 응답 본문을 1MB(1,048,576바이트) 이하로 만든다
* [ ] 시크릿을 상수 시간 비교(`timingSafeEqual`, `hash_equals` 등)로 확인한다
* [ ] ID/PW 를 로그에 남기지 않는다
* [ ] 같은 요청을 여러 번 받아도 데이터가 바뀌지 않는다 (쏠브가 재시도할 수 있음)
체크리스트 항목 중 응답으로 확인할 수 있는 것(맞는·틀린 비밀번호, 틀린 시크릿, `user_key` 형식, 응답 시간)은 [전체 점검 도구](https://developers.solve.im/docs/partner-integration/onboarding)로 한 번에 점검할 수 있습니다.
## 관련 문서
* [구매내역 API](https://developers.solve.im/docs/partner-integration/purchase-history-api) · [보안 요구사항](https://developers.solve.im/docs/partner-integration/security) · [테스트와 출시](https://developers.solve.im/docs/partner-integration/onboarding)
# ⓑ 호스팅 로그인 페이지
URL: https://developers.solve.im/docs/partner-integration/login-hosted-page
> 제휴사 로그인 페이지에서 인증한 뒤 쏠브 콜백을 호출하는 방식입니다. 소셜 로그인을 운영하는 제휴사용입니다.
쏠브 앱이 제휴사의 로그인 페이지를 열면, 회원은 **평소처럼 제휴사 사이트에서 로그인**합니다. 로그인이 끝나면 제휴사 서버가 쏠브 콜백 API 를 호출하고, 받은 `redirect_url` 로 회원을 보내면 연동이 완료됩니다. 카카오·네이버·구글·Apple 같은 소셜 로그인을 운영하거나, 자체 ID/PW 와 소셜 로그인을 함께 운영하는 제휴사는 이 방식을 사용합니다.
계약 후 쏠브가 **콜백 API URL**, 제휴사의 **`platform_type`**(숫자), **콜백 인증 시크릿**을 전달합니다. 제휴사는 **쏠브 전용 로그인 페이지 URL** 을 쏠브에 알려주세요.
## 흐름
## 1단계. 쏠브 전용 로그인 페이지 준비
* 쏠브 앱은 등록된 URL 을 **그대로** 엽니다. 쿼리 파라미터를 붙이지 않습니다.
* 일반 로그인과 구분할 수 있도록 **쏠브 전용 URL**(예: `https://www.partner.example.com/solve/login`)을 따로 두세요. 이 URL 로 들어온 로그인만 쏠브 콜백으로 이어집니다.
* 화면 구성과 인증 방식은 자유입니다. 기존 로그인 화면을 그대로 써도 됩니다.
* 소셜 로그인처럼 외부 사이트를 다녀오는 경우, 세션에 "쏠브 연동 중" 표시를 남겨 두었다가 로그인이 끝나는 시점에 2단계를 진행하세요.
## 2단계. 로그인 성공 후 쏠브 콜백 호출
로그인에 **성공했을 때만** 제휴사 **서버**에서 호출합니다. 콜백 인증 시크릿이 들어가므로 브라우저에서 호출하면 안 됩니다.
```bash title="콜백 요청 (curl 로 재현)"
curl -X POST '<쏠브가 전달한 콜백 API URL>' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <쏠브가 발급한 콜백 인증 시크릿>' \
-d '{"id": "100023", "platform_type": <쏠브가 부여한 platform_type>}'
```
| 헤더 | 값 | 설명 |
| --------------- | -------------- | -------------------------------------------------------------- |
| `Authorization` | `Bearer <시크릿>` | 쏠브가 제휴사별로 발급한 콜백 인증 시크릿. 서버 설정(환경변수 등)에만 두고 로그·브라우저에 노출하지 마세요. |
| 필드 | 타입 | 필수 | 설명 |
| ------------------------ | ------ | -- | ------------------------------------ |
| `id` | string | 필수 | 회원의 불변·유일 식별자(= `user_key`), 최대 255자 |
| `platform_type` | number | 필수 | 쏠브가 부여한 제휴사 식별자 |
| `solve_sso_verify_token` | string | 선택 | 과거 버전 호환용입니다. 보내도 무시하며 생략해도 됩니다. |
카카오·네이버 등으로 로그인했더라도 **제휴사 내부 회원의 불변 식별자**(회원번호 등)를 보내야 합니다. 값이 바뀌면 회원은 연동과 책장의 책을 잃고, 탈퇴한 회원의 값을 다른 회원에게 다시 주면 안 됩니다.
### 응답
| 상태 코드 | 본문 | 의미와 처리 |
| ----- | ------------------------------------------------------ | ------------------------------------------------------------------------------ |
| `200` | `{ "redirect_url": "https://..." }` | 3단계로 진행합니다. |
| `400` | `{ "error": "BadRequest", "message": "..." }` | `platform_type` 이 틀렸거나 비활성 상태입니다. 쏠브에 문의해 주세요. |
| `401` | `{ "error": "Unauthorized", "message": "..." }` | 인증 시크릿이 없거나 틀렸습니다. 재시도하지 말고 설정을 확인하세요. |
| `429` | | 쏠브 콜백은 분당 600회까지 받습니다. 호출 한도를 넘었습니다. 응답의 `Retry-After` 헤더(초)만큼 기다린 뒤 다시 시도하세요. |
| `500` | `{ "error": "InternalServerError", "message": "..." }` | 쏠브 일시 오류입니다. 회원에게 잠시 후 다시 시도하도록 안내하세요. |
## 3단계. 같은 웹뷰에서 redirect\_url 로 이동
받은 `redirect_url` 로 회원의 브라우저를 **그대로** 이동시킵니다(HTTP 302 또는 `window.location.href`). URL 에는 쏠브가 위변조 방지용 서명 파라미터를 함께 담아 보내므로, 파라미터를 빼거나 순서를 바꾸는 등 **어떤 수정도 하지 마세요.**
쏠브는 연동을 시작한 웹뷰에 1회용 토큰을 발급해 두고, `redirect_url` 로 돌아왔을 때 확인합니다. 로그인부터 이동까지 **쏠브 앱이 연 같은 웹뷰 안에서** 이어져야 합니다. `target="_blank"`, 외부 브라우저 열기, 팝업 로그인 뒤 부모 창 이동 등을 쓰지 마세요.
재생 공격·CSRF 방지는 이 1회용 토큰으로 쏠브가 처리하므로, 제휴사가 `state`·nonce 를 따로 구현할 필요는 없습니다.
## 구현 예시
제휴사 로그인이 성공한 직후 호출하는 함수입니다. 일반 로그인 핸들러와 소셜 로그인 콜백 핸들러 양쪽에서, "쏠브 연동 중"인 세션일 때만 호출하세요.
Node.js
Python
Java
PHP
```js
// 제휴사 로그인 성공 직후, 쏠브 연동 중인 세션에서만 호출
async function completeSolveLink(req, res, member) {
const response = await fetch(process.env.SOLVE_CALLBACK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.SOLVE_CALLBACK_SECRET}`, // 쏠브가 발급한 콜백 인증 시크릿
},
body: JSON.stringify({
id: String(member.memberNo), // 소셜 ID 가 아닌 내부 회원번호
platform_type: Number(process.env.SOLVE_PLATFORM_TYPE),
}),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
console.error('solve callback failed', response.status);
return res.status(502).send('쏠브 연동 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.');
}
const { redirect_url } = await response.json();
delete req.session.solveLinking;
return res.redirect(302, redirect_url); // 같은 웹뷰에서 이동
}
```
```python
import logging
import os
import requests
from fastapi import Request
from fastapi.responses import PlainTextResponse, RedirectResponse
logger = logging.getLogger(__name__)
def complete_solve_link(request: Request, member):
"""제휴사 로그인 성공 직후, 쏠브 연동 중인 세션에서만 호출"""
try:
resp = requests.post(
os.environ["SOLVE_CALLBACK_URL"],
headers={"Authorization": f"Bearer {os.environ['SOLVE_CALLBACK_SECRET']}"}, # 쏠브 발급 시크릿
json={
"id": str(member.member_no), # 소셜 ID 가 아닌 내부 회원번호
"platform_type": int(os.environ["SOLVE_PLATFORM_TYPE"]),
},
timeout=10,
)
resp.raise_for_status()
except requests.RequestException:
logger.exception("solve callback failed")
return PlainTextResponse("쏠브 연동 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.", status_code=502)
request.session.pop("solve_linking", None)
return RedirectResponse(resp.json()["redirect_url"], status_code=302) # 같은 웹뷰에서 이동
```
```java
// 제휴사 로그인 성공 직후, 쏠브 연동 중인 세션에서만 호출
// restClient(RestClient), solveCallbackUrl·solvePlatformType·solveCallbackSecret(설정값), log(Logger)는 클래스 필드
public String completeSolveLink(HttpSession session, Member member) {
Map body = Map.of(
"id", String.valueOf(member.getMemberNo()), // 소셜 ID 가 아닌 내부 회원번호
"platform_type", solvePlatformType
);
try {
Map, ?> result = restClient.post()
.uri(solveCallbackUrl)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", "Bearer " + solveCallbackSecret) // 쏠브 발급 시크릿
.body(body)
.retrieve()
.body(Map.class);
session.removeAttribute("solveLinking");
return "redirect:" + result.get("redirect_url"); // 같은 웹뷰에서 이동
} catch (RestClientException e) {
log.error("solve callback failed", e);
return "solve/link-error";
}
}
```
```php
true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . getenv('SOLVE_CALLBACK_SECRET'), // 쏠브 발급 시크릿
],
CURLOPT_POSTFIELDS => json_encode([
'id' => (string) $member['member_no'], // 소셜 ID 가 아닌 내부 회원번호
'platform_type' => (int) getenv('SOLVE_PLATFORM_TYPE'),
]),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($raw === false || $status !== 200) {
error_log("solve callback failed: {$status}");
http_response_code(502);
echo '쏠브 연동 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.';
exit;
}
unset($_SESSION['solve_linking']);
header('Location: ' . json_decode($raw, true)['redirect_url'], true, 302); // 같은 웹뷰에서 이동
exit;
}
```
## 콜백을 미리 시험하기
콜백 주소와 시크릿은 계약 뒤에 받지만, 그 전에도 제휴사 로그인 페이지가 콜백을 규약대로 보내는지 확인할 수 있습니다. [solve-callback-mock.mjs 내려받기](/tools/solve-callback-mock.mjs)로 자기 PC 에 "쏠브 콜백 API 흉내"를 띄우세요. 파일 하나이며 Node.js 18 이상만 있으면 설치할 것이 없고, 받기만 할 뿐 밖으로는 아무것도 보내지 않습니다. 쏠브 서버와는 아무 관계가 없습니다.
시험용 시크릿과 `platform_type` 을 환경변수로 넘겨 실행합니다. 시크릿은 직접 정하되 쏠브가 받아 주는 값과 같은 조건이어야 합니다 — 32자 이상 4096자 이하, 영문·숫자·기호(공백 포함)만, 앞뒤 공백 없이. 쏠브는 32자 미만의 콜백 시크릿을 쓸 수 없는 값으로 보고 모든 콜백을 `401` 로 거절하므로, 짧은 값으로는 도구가 시작하지 않습니다. `platform_type` 은 쏠브가 부여하는 양의 정수입니다(`0` 이나 소수는 안 됩니다).
```bash title="콜백 흉내 도구 실행 (macOS·Linux)"
SOLVE_MOCK_CALLBACK_SECRET=<시험용 값> SOLVE_MOCK_PLATFORM_TYPE=<숫자> node solve-callback-mock.mjs
```
```powershell title="콜백 흉내 도구 실행 (Windows PowerShell)"
$env:SOLVE_MOCK_CALLBACK_SECRET = "<시험용 값>"; $env:SOLVE_MOCK_PLATFORM_TYPE = "<숫자>"; node solve-callback-mock.mjs
```
도구가 시작하면 `http://127.0.0.1:4310/solve-callback` 처럼 콜백 주소를 알려 줍니다. 제휴사 서버의 "쏠브 콜백 URL" 을 그 주소로, 콜백 시크릿을 위에서 정한 시험용 값으로, `platform_type` 을 같은 숫자로 바꾼 뒤 자기 로그인 페이지에서 로그인해 보세요. 쏠브가 계약 뒤에 전달하는 진짜 콜백 시크릿은 이 도구에 넣지 마세요.
| 확인하는 것 | 결과 |
| -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
| 정상 요청 | `200` 과 `redirect_url` |
| 시크릿이 없거나 틀림 | `401` |
| `id` 가 문자열이 아니거나 비었거나 256자 이상, `platform_type` 이 양의 정수가 아니거나 없거나 다름, `solve_sso_verify_token` 이 있는데 문자열이 아님(`null` 포함), `Content-Type` 이 JSON 이 아님 | `400` |
| `platform_type` 이 숫자 모양의 문자열 | `200`, 단 콘솔에 "숫자로 보내세요" 주의 |
| `POST` 가 아닌 요청 | `405` |
본문이 틀리면 시크릿이 틀려도 `400` 입니다 — 쏠브도 인증보다 본문을 먼저 보기 때문이며, 콘솔 줄에는 시크릿 문제도 함께 나옵니다. 요청마다 콘솔에 점검 결과가 한 줄씩 나옵니다. 시크릿 값은 찍지 않고 "일치"·"불일치"만 알려 줍니다. 받은 요청의 본문·헤더·주소 원문도 화면에 싣지 않고, `id` 는 길이만, 필드는 이름이 아닌 통과 여부만 알려 줍니다. `--next 429` 또는 `--next 500` 을 붙이면 다음 정상 요청 한 번에 그 응답(`429` 는 `Retry-After: 1`)을 돌려주므로 제휴사의 오류 처리도 시험할 수 있습니다. `--port`·`--host` 로 주소를 바꿀 수 있고, `--host` 를 `127.0.0.1` 밖으로 바꾸면 같은 네트워크의 다른 기기에서도 접근됩니다.
도구가 돌려준 `redirect_url` 은 `/done` 주소입니다. 제휴사 서버가 이 주소로 회원의 브라우저를 수정 없이 보내면 "연동 흉내가 끝났습니다" 화면에 `PASS` 가 나오고, 쿼리를 하나라도 바꾸면 `FAIL` 과 달라진 값의 개수(빠짐·값이 바뀜·추가됨)가 나옵니다. 이름과 값은 화면에 싣지 않습니다. 쏠브가 만든 연동 주소의 서명은 10분 뒤 만료되므로, 도구도 `redirect_url` 을 받은 지 10분이 지나서 열면 `FAIL` 로 알립니다. 주소의 `sig` 같은 값은 서명처럼 보이게 만든 임의 값이며 진짜 서명이 아닙니다.
## 구현 체크리스트
* [ ] 쏠브 전용 로그인 URL 을 쏠브에 전달했다 (쿼리 파라미터 없이 동작)
* [ ] 로그인에 **성공했을 때만**, 제휴사 **서버**에서 `Authorization: Bearer <시크릿>` 을 담아 콜백을 호출한다
* [ ] 콜백 인증 시크릿이 브라우저·로그에 노출되지 않는다
* [ ] `id` 로 소셜 계정 ID·이메일이 아닌 내부 회원의 불변 식별자를 보낸다
* [ ] `redirect_url` 을 수정 없이, 같은 웹뷰에서 연다
* [ ] 소셜 로그인으로 외부를 다녀와도 "쏠브 연동 중" 상태가 유지된다
## 관련 문서
* [구매내역 API](https://developers.solve.im/docs/partner-integration/purchase-history-api) · [보안 요구사항](https://developers.solve.im/docs/partner-integration/security) · [테스트와 출시](https://developers.solve.im/docs/partner-integration/onboarding)
# 테스트와 출시
URL: https://developers.solve.im/docs/partner-integration/onboarding
> 셀프 점검, 쏠브에 보낼 테스트 자료, 쏠브 검증 항목, 출시 체크리스트를 정리했습니다.
구현이 끝나면 **셀프 점검 → 테스트 자료 전달 → 쏠브 검증 → 출시** 순서로 진행합니다. 공개 샌드박스는 없으며, 제휴사 테스트 환경(또는 운영 환경의 테스트 계정)으로 검증합니다.
## 1. 셀프 점검
쏠브에 자료를 보내기 전에, 제휴사 서버가 규약대로 응답하는지 아래 도구로 확인해 보세요. 두 도구 모두 제휴사 PC 에서 돌리며, 쏠브 서버로는 아무것도 보내지 않습니다.
ⓑ 호스팅 로그인 페이지 방식이라면 콜백 주소와 시크릿을 계약 뒤에야 받습니다. 그 전에는 [콜백 흉내 도구](https://developers.solve.im/docs/partner-integration/login-hosted-page)로 자기 로그인 페이지가 콜백을 규약대로 보내는지 미리 확인해 보세요.
### 전체 점검 (Node)
Node.js 18 이상이 있으면 구매내역 API 와 ⓐ 로그인 API 를 한 번에 점검할 수 있습니다. 응답 형식, 선택 필드(v1.1), 오류 응답, 응답 시간(10초)까지 확인하고 점검마다 `PASS`·`WARN`·`FAIL`·`SKIP` 으로 알려 줍니다.
[solve-selfcheck.mjs 내려받기](/tools/solve-selfcheck.mjs) — 파일 하나이며 설치할 것이 없습니다. 브라우저에 코드가 그대로 열리면 "다른 이름으로 저장"으로 받아 주세요.
먼저 설정 파일 틀을 만듭니다. 같은 이름의 파일이 이미 있으면 덮어쓰지 않습니다.
```bash title="설정 파일 틀 만들기"
node solve-selfcheck.mjs --init
```
만들어진 `solve-selfcheck.config.json` 에 제휴사 값을 적습니다.
```json title="solve-selfcheck.config.json"
{
"purchaseUrl": "https://api.partner.example.com/solve/purchases",
"authHeaderName": "X-Solve-Secret",
"secret": "여기에-점검용-시크릿",
"userKey": "여기에-구매-내역이-있는-회원의-user_key",
"loginUrl": "",
"loginId": "",
"loginPassword": ""
}
```
| 설정 | 설명 |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `purchaseUrl` | 구매내역 API URL. 2048자 이하이고 공백이나 `아이디:비밀번호@` 를 넣을 수 없습니다 |
| `authHeaderName`, `secret` | 쏠브가 보낼 인증 헤더 이름과 값. 이름은 영문·숫자·기호 64자 이하이며 `Host`·`Content-Type`·`Cookie`·`Proxy-*`·`X-Forwarded-*` 같은 이름은 쓸 수 없습니다. 값은 16자 이상 4096자 이하이고 앞뒤에 공백이나 탭이 없어야 합니다 |
| `userKey` | 구매 내역이 있는 테스트 회원의 `user_key`. 앞뒤 공백까지 그대로 씁니다 |
| `loginUrl`, `loginId`, `loginPassword` | ⓐ 방식만 적습니다. 로그인 API URL 과 테스트 계정입니다. 비워 두면 로그인 점검은 건너뜁니다 |
```bash title="점검 실행"
node solve-selfcheck.mjs solve-selfcheck.config.json
```
```text title="모두 통과하면"
쏠브 셀프 점검 v2 (도구 1.0.0)
구매내역 API: https://api.partner.example.com (경로와 쿼리는 화면에 싣지 않습니다)
로그인 API: https://api.partner.example.com
호출마다 10.00초 제한을 두고 걸린 시간을 보여 드립니다.
PASS [1] 구매내역 · 구매 회원 조회 (HTTP 200, 0.21초) item_list 2개, 모든 항목에 item_code 가 있습니다
PASS [2] 구매내역 · 없는 회원 조회 (HTTP 200, 0.09초) 200 과 빈 item_list 로 응답했습니다
PASS [3] 구매내역 · 틀린 시크릿 (HTTP 403, 0.05초) 403 응답으로 거절했습니다
PASS [4] 구매내역 · 인증 헤더 없음 (HTTP 403, 0.04초) 403 응답으로 거절했습니다
PASS [5] 구매내역 · user_key 없는 요청 (HTTP 400, 0.04초) 400 응답으로 거절했습니다
SKIP [6] 구매내역 · 선택 필드 형식 선택 필드 없음 — 없어도 됩니다
PASS [8] 로그인 · 맞는 아이디·비밀번호 (HTTP 200, 0.12초) user_key 를 받았습니다 (6자)
PASS [9] 로그인 · 틀린 비밀번호 (HTTP 401, 0.10초) 401 응답으로 거절했습니다
PASS [10] 로그인 · 틀린 시크릿 (HTTP 403, 0.04초) 403 응답으로 거절했습니다
PASS [11] 로그인 + 구매내역 · 로그인과 구매내역의 user_key 로그인이 돌려준 user_key 가 설정의 userKey 와 같습니다
PASS [7] 공통 · 응답 시간 모든 호출이 10.00초 안에 끝났습니다 (가장 느린 호출 0.21초)
PASS [12] 공통 · 주소가 https 인지 https 입니다 (같은 PC 의 localhost 는 예외)
요약: 통과 11 · 주의 0 · 실패 0 · 건너뜀 1
실패는 없습니다.
참고: 이 도구의 통과는 운영 등록 가능의 증거가 아닙니다. http 주소는 실제 등록에서 거부됩니다.
```
`FAIL` 이나 `WARN` 이 나오면 바로 아래에 고치는 방법과 응답 요약이 함께 나옵니다. 응답 요약은 본문의 바이트 수, JSON 의 종류, 규약에 있는 필드의 타입과 길이, 그 밖의 필드 개수만 보여 줍니다. 이 도구는 응답의 본문·헤더 원문과 시크릿·비밀번호를 화면에 싣지 않으며, 주소도 경로와 쿼리 없이 `https://호스트` 까지만 보여 줍니다.
```text title="고칠 것이 있으면"
FAIL [2] 구매내역 · 없는 회원 조회 (HTTP 404, 0.04초) 200 이 아니라 404 응답을 보냈습니다
고치는 방법: 없는 회원은 404 나 오류가 아니라 200 과 { "item_list": [] } 로 응답해 주세요.
응답 요약: 본문 21바이트 · JSON 객체 · error: 문자열 9자
```
| 결과 | 뜻 |
| ------ | --------------------------------------------- |
| `PASS` | 규약대로입니다 |
| `WARN` | 쏠브는 동작하지만 권장과 다릅니다. 고치는 방법을 확인해 주세요 |
| `FAIL` | 규약과 다릅니다. 고친 뒤 다시 실행해 주세요 |
| `SKIP` | 해당하지 않아 건너뛰었습니다 (선택 필드가 없거나 로그인 주소를 적지 않은 경우) |
이 도구는 쏠브가 응답을 읽는 방식과 같은 기준으로 판정합니다. 쏠브가 응답 전체를 쓰지 못하는 경우는 `FAIL`, 응답은 받되 그 필드만 버리는 경우는 `WARN` 입니다. 위 설정 값이 쏠브가 받아 주는 조건을 어기면 점검하지 않고 종료 코드 `2` 로 끝납니다.
| 점검 | `FAIL` — 쏠브가 응답을 쓰지 못합니다 | `WARN` — 쏠브가 그 필드만 버립니다 |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1·2번 구매내역 응답 | `200` 이 아님(`3xx` 포함) · JSON 객체가 아님 · `item_list` 가 배열이 아님 · 항목이 객체가 아님 · `item_code` 가 문자열이 아니거나, 앞뒤 공백을 뗀 뒤 비어 있거나 255자를 넘음. 항목 하나만 틀려도 목록 전체를 버립니다 | — |
| 6번 선택 필드 | — | `price` 가 0 이상의 안전한 정수(9007199254740991 이하)나 1~~12자리 숫자 문자열이 아님 · `paid_at` 이 존재하는 날짜의 ISO 8601 이 아니거나 오프셋의 시가 23, 분이 59 를 넘음 · `valid_from`·`valid_until` 이 앞뒤 공백을 뗀 뒤 1~~32자 문자열이 아님 · `item_name` 이 문자열이 아님. `null` 은 보내지 않은 것과 같습니다 |
| 8번 로그인 응답 | `200` 이 아님(`3xx` 포함) · JSON 객체가 아님 · `user_key` 가 문자열이 아니거나 공백뿐이거나 255자를 넘음 | — |
응답 본문은 1MB(1,048,576바이트) 까지만 읽습니다. 정확히 1MB 는 통과하고, 1바이트라도 넘으면 그 호출은 "응답이 1MB 를 넘습니다. 쏠브는 1MB 를 넘는 응답을 받지 않습니다." 와 함께 `FAIL` 입니다. 쏠브도 1MB 를 넘는 응답을 받지 않기 때문입니다. 잘린 본문으로는 어떤 점검도 `PASS` 로 판정하지 않습니다. 구매내역·로그인 응답은 필요한 필드만 담아 1MB 이하로 줄여 주세요.
종료 코드는 `FAIL` 이 없으면 `0`, 하나라도 있으면 `1`, 설정이나 실행 환경에 문제가 있으면 `2` 입니다. 이 도구는 설정 파일에 적은 주소만 호출하고 점검 하나에 호출 하나만 보내며, 리다이렉트를 따라가지 않습니다. 쏠브도 다른 주소로 넘기는 응답을 따라가지 않으므로 `3xx` 응답은 `FAIL` 이고, 이때는 설정에 최종 주소를 적어 주세요. 쏠브 서버 주소는 점검 대상이 아니라서 호출하지 않고 종료 코드 `2` 로 끝납니다. 로그인 점검은 틀린 비밀번호로 한 번 로그인을 시도하므로, 계정 잠금 정책이 있다면 테스트 계정으로 하세요.
이 도구의 통과는 운영 등록 가능의 증거가 아닙니다. 12번이 `WARN` 인 `http` 주소는 종료 코드 `0` 이어도 쏠브에 실제로 등록할 때 거부됩니다.
### 빠른 확인 (bash)
`curl` 과 `bash` 만 있으면 구매내역 API 3건을 바로 확인할 수 있습니다. 맨 위 4개 값만 바꾸면 됩니다.
```bash title="solve-selfcheck.sh"
#!/usr/bin/env bash
# 쏠브 연동 셀프 점검 — 구매내역 API
URL="https://api.partner.example.com/solve/purchases" # 구매내역 API URL
SECRET_HEADER="X-Solve-Secret: 여기에-시크릿" # 쏠브가 보낼 인증 헤더
USER_KEY="100023" # 구매 내역이 있는 테스트 회원
UNKNOWN_KEY="solve-selfcheck-no-such-user" # 존재하지 않는 회원
BODY_FILE=$(mktemp)
call() { # $1=인증 헤더 $2=요청 본문 → "상태코드 소요초" 출력
curl -s -o "$BODY_FILE" -w '%{http_code} %{time_total}' --max-time 10 \
-X POST "$URL" -H 'Content-Type: application/json' -H "$1" -d "$2"
}
report() { # $1=점검명 $2=통과 여부(0/1) $3=상세
if [ "$2" = 1 ]; then echo "PASS $1 ($3)"; else echo "FAIL $1 ($3)"; fi
echo " 응답: $(head -c 300 "$BODY_FILE")"
}
read -r code secs <<<"$(call "$SECRET_HEADER" "{\"user_key\":\"$USER_KEY\"}")"
ok=0; [ "$code" = 200 ] && tr -d ' \n\r\t' < "$BODY_FILE" | grep -q '"item_code":"' && ok=1
report "구매 회원 → 200 + item_code 포함" $ok "HTTP $code, ${secs}s"
read -r code secs <<<"$(call "$SECRET_HEADER" "{\"user_key\":\"$UNKNOWN_KEY\"}")"
ok=0; [ "$code" = 200 ] && tr -d ' \n\r\t' < "$BODY_FILE" | grep -q '"item_list":\[\]' && ok=1
report "없는 회원 → 200 + 빈 item_list" $ok "HTTP $code, ${secs}s"
read -r code secs <<<"$(call "X-Solve-Secret: wrong-secret" "{\"user_key\":\"$USER_KEY\"}")"
ok=0; [[ "$code" =~ ^4 ]] && ok=1
report "틀린 시크릿 → 4xx" $ok "HTTP $code, ${secs}s"
rm -f "$BODY_FILE"
```
```text title="모두 통과하면"
PASS 구매 회원 → 200 + item_code 포함 (HTTP 200, 0.21s)
PASS 없는 회원 → 200 + 빈 item_list (HTTP 200, 0.09s)
PASS 틀린 시크릿 → 4xx (HTTP 403, 0.05s)
```
ⓐ 방식이라면 [로그인 API 의 curl 예시](https://developers.solve.im/docs/partner-integration/login-credential-api)로 맞는 비밀번호(`200` + `user_key`)와 틀린 비밀번호(`4xx`)도 확인해 보세요.
## 2. 쏠브에 보낼 테스트 자료
아래 자료를 쏠브 담당자 이메일로 보내주세요. 담당자 주소는 계약 시 안내해 드립니다.
| 자료 | ⓐ 자격증명 API | ⓑ 호스팅 로그인 페이지 |
| ------ | --------------------------- | --------------------------------- |
| 로그인 | 로그인 API URL | 쏠브 전용 로그인 페이지 URL |
| 구매내역 | 구매내역 API URL | 구매내역 API URL |
| 인증 | 시크릿과 헤더 이름 | 시크릿과 헤더 이름 |
| 테스트 계정 | ID/PW (구매 내역이 있는 계정) | 로그인 가능한 테스트 회원 + 그 회원의 `user_key` |
| 비교용 | 그 계정이 보유한 `item_code` 와 상품명 | 같음 |
시크릿·소셜 계정처럼 민감한 정보는 메일 본문 대신 별도 보안 채널로 전달 방법을 협의합니다.
## 3. 쏠브가 확인하는 것
| 구분 | 확인 내용 |
| --- | ------------------------------------------------- |
| API | 응답 형식, `user_key` 가 매번 같은지, 환불 항목이 빠지는지, 오류 코드 구분 |
| 연동 | 쏠브 앱에서 로그인 → 연동 완료까지 (ⓑ 는 같은 웹뷰 안에서 끝나는지) |
| 책장 | 테스트 계정의 `item_code` 에 맞는 책이 책장에 보이는지 |
| 회수 | 테스트 계정에서 상품을 뺐을 때 다음 동기화에 책장에서 빠지는지 |
제휴사에서도 같은 계정으로 응답을 점검해 주세요.
## 4. 출시 전 체크리스트
* [ ] 로그인(ⓐ 또는 ⓑ)이 동작하고, `user_key` 가 불변·유일하다
* [ ] 구매내역 API 가 환불·만료 항목을 빼고, 없는 회원에게 빈 배열(200)을 준다
* [ ] 장애·부분 조회 실패 때는 `5xx` 로 응답한다
* [ ] 판매 중인 전자책마다 연동코드가 입력됐다 (카탈로그 파일 방식이면 전체 목록을 전달했다)
* [ ] 모든 통신이 HTTPS 이고, 서버 간 호출에 시크릿 인증이 적용됐다
* [ ] 테스트 계정으로 쏠브 앱 책장에 책이 보인다
* [ ] 롤백 방법과 연락 창구를 양쪽이 합의했다
## 출시 후
* **회원 안내**: 연동은 회원이 쏠브 앱에서 직접 시작하므로, 제휴사 사이트에서 쏠브 앱을 안내하면 이용률이 크게 올라갑니다. [구매 후 사용자 안내](https://developers.solve.im/docs/partner-integration/user-discovery)를 참고하세요.
* **시크릿 회전·URL 변경**: 미리 알려 주세요. 새 값(주소)과 옛 값(주소)을 **함께 받아 주시는 동안** 쏠브가 새 값으로 바꿉니다(반영까지 최대 5분). 바뀐 것을 확인하신 뒤 옛 값을 폐기하시면 됩니다. ([보안 요구사항](https://developers.solve.im/docs/partner-integration/security))
* **롤백**: 연동을 끄면 신규 연동과 동기화가 멈춥니다. 이미 들어간 책은 남습니다. 회수가 필요하면 쏠브 담당자와 범위·시점을 협의해 주세요.
# 개념과 용어
URL: https://developers.solve.im/docs/partner-integration/overview
> 연동의 2단계 구조, 꼭 알아야 할 용어, 로그인 방식 고르는 법을 설명합니다.
구현을 시작하기 전에 알아야 할 개념을 정리했습니다. 이 페이지만 읽어도 전체 구조를 이해할 수 있습니다.
## 연동은 두 단계입니다
1. **연동(Linking)**: 회원이 쏠브 앱에서 제휴사 계정으로 로그인하면, 쏠브가 그 회원의 `user_key` 를 저장합니다. 회원당 한 번만 일어납니다.
2. **동기화(Sync)**: 쏠브가 `user_key` 로 구매내역 API 를 호출해, 받은 상품코드에 맞는 책을 책장에 넣거나 뺍니다.
동기화는 쏠브가 필요할 때 제휴사 API 를 호출하는 방식(pull)입니다. 제휴사가 쏠브에 구매 소식을 따로 알려줄 필요는 없습니다.
| 동기화 시점 | 설명 |
| ------ | ----------------------------- |
| 연동 직후 | 로그인이 끝나면 1회 자동으로 실행됩니다. |
| 책장 진입 | 회원이 쏠브 앱의 책장에 들어갈 때 실행됩니다. |
| 전체 동기화 | 회원이 앱에서 "전체 동기화"를 누를 때 실행됩니다. |
## 용어
| 용어 | 뜻 |
| --------------- | -------------------------------------------------------------------------- |
| `user_key` | 제휴사가 정하는 회원 식별자입니다. **한 번 정해지면 바뀌지 않고, 회원끼리 겹치지 않아야** 합니다. 내부 회원번호를 권장합니다. |
| `item_code` | 제휴사의 상품코드입니다. 구매내역 API 응답과 쏠브 상품을 잇는 키입니다. 파트너 어드민에서는 **연동코드**라고 부릅니다. |
| 연동코드 | 파트너 어드민에서 전자책을 등록할 때 입력하는 값으로, `item_code` 와 같습니다. |
| 시크릿 | 쏠브가 제휴사 API 를 호출할 때 헤더에 담는 인증 값입니다. **제휴사가 발급**해 쏠브에 전달합니다. |
| `platform_type` | 쏠브가 제휴사마다 부여하는 숫자 식별자입니다. ⓑ 방식의 콜백에서만 씁니다. |
아이디·이메일은 회원이 바꿀 수 있습니다. `user_key` 가 바뀌면 회원은 연동과 책장의 책을 모두 잃습니다. 탈퇴한 회원의 값을 다른 회원에게 다시 주는 것도 안 됩니다.
## 로그인 방식 고르기
| | ⓐ 자격증명 로그인 API | ⓑ 호스팅 로그인 페이지 |
| ----------------- | ----------------------------------- | -------------------------------- |
| 이럴 때 | 자체 ID/PW 로그인만 운영 | 카카오·네이버·구글·Apple 등 소셜 로그인을 함께 운영 |
| 제휴사가 만드는 것 | 로그인 API 1개 | 로그인 페이지 + 쏠브 콜백 호출 |
| ID/PW 가 쏠브를 거치나요? | 예 (TLS, 로그에 남기지 않음) | 아니요 |
| 구현 난이도 | 낮음 | 중간 |
| 상세 | [ⓐ 페이지](https://developers.solve.im/docs/partner-integration/login-credential-api) | [ⓑ 페이지](https://developers.solve.im/docs/partner-integration/login-hosted-page) |
소셜로 가입한 회원은 ID/PW 가 없어서 ⓐ 로는 연동할 수 없습니다. 소셜 로그인이 하나라도 있으면 ⓑ 를 고르세요.
## 쏠브가 정하는 것과 제휴사가 정하는 것
| 쏠브가 정합니다 | 제휴사가 정합니다 |
| ------------------------------------ | --------------- |
| `user_key` 는 불변·유일해야 한다는 규칙 | 로그인 방식 (ⓐ 또는 ⓑ) |
| 구매내역 응답 형식 (`item_list[].item_code`) | API 엔드포인트 URL |
| ⓑ 콜백 형식 (`id`, `platform_type`) | 인증 헤더 이름과 방식 |
| 현재 유효한 구매만 돌려준다는 규칙 | 내부 데이터 구조 |
# 상품 연결 (연동코드·카탈로그)
URL: https://developers.solve.im/docs/partner-integration/product-catalog
> 구매내역 API 의 item_code 를 쏠브 전자책에 연결하는 방법입니다. 파트너 어드민 등록 또는 카탈로그 파일로 진행합니다.
구매내역 API 가 돌려준 `item_code` 가 쏠브의 어떤 전자책인지 알려주는 단계입니다. 방법은 두 가지이고, 제휴사 상황에 맞는 하나를 고르면 됩니다.
| 방법 | 이런 제휴사 | 제휴사가 할 일 |
| ---------------------- | --------------------------- | ------------------------------------------ |
| **A. 파트너 어드민 등록** (권장) | 쏠브 파트너 어드민에 전자책 PDF 를 직접 등록 | 등록할 때 연동코드 입력. 이미 등록한 전자책은 상품 상세에서 한 번 입력. |
| **B. 카탈로그 파일** | 쏠브가 전자책을 대신 등록 | 상품 목록 파일 전달, 신상품마다 갱신 |
구매내역 API 가 돌려준 `item_code` 중 연동코드나 카탈로그에 없는 코드는 무시됩니다. 판매 중인 전자책 상품을 빠짐없이 연결해 주세요.
## A. 파트너 어드민에 등록하기
### 전자책 등록 화면 열기
쏠브 파트너 어드민에서 전자책 PDF 등록을 시작합니다. 어드민 계정은 계약 시 안내해 드립니다.
### 판매유형 고르기
| 판매유형 | 뜻 |
| --------- | ---------------------------------------- |
| `연동형` | 제휴사 사이트에서 구매한 회원에게만 제공합니다. |
| `연동+쏠브북스` | 제휴사 구매 회원에게 제공하고, 쏠브북스에서도 판매합니다. |
| `쏠브북스` | 쏠브북스에서만 판매합니다. 연동과 무관하며 연동코드란이 보이지 않습니다. |
### 연동코드 입력
**연동코드**란에 구매내역 API 가 돌려주는 `item_code` 를 그대로 입력합니다. 쏠브는 `item_code` 의 **앞뒤 공백을 떼고** 비교합니다. 그 밖은 대소문자까지 글자 그대로 같아야 합니다. 연동코드 앞뒤에는 공백을 넣지 마세요.
이렇게 등록하면 그 `item_code` 를 구매한 회원의 책장에 책이 자동으로 들어갑니다. **등록 전에 구매한 회원도** 다음 동기화(책장 진입 등) 때 책이 들어갑니다. 카탈로그 파일은 따로 보내지 않아도 됩니다.
### 이미 등록한 전자책에 연동코드 넣기
이미 등록한 전자책은 파트너 어드민의 상품 상세 화면에서 연동코드를 넣을 수 있습니다.
* 연동코드가 비어 있을 때 **한 번만** 넣을 수 있습니다. 저장하면 바꿀 수 없으니 제휴사 쇼핑몰의 상품코드와 똑같이 넣어 주세요.
* 쏠브북스에서만 판매하던 전자책은 저장하면 판매유형이 `연동+쏠브북스` 로 바뀝니다.
* 한 상품코드에 여러 권이 묶인 패키지는 포함된 각 전자책에 같은 연동코드를 넣습니다. 같은 연동코드가 이미 다른 전자책에 들어 있으면 저장할 때 한 번 더 확인합니다.
### 패키지·세트 상품
| 경우 | 입력 방법 |
| --------------------------------- | ------------------------------------------------------------ |
| 한 상품코드에 여러 권이 묶인 패키지 | 포함된 **각 전자책**을 등록할 때 같은 연동코드를 입력합니다. |
| 한 권이 여러 상품코드에 동시에 포함 (단품 + 패키지 등) | 연동코드란에는 코드 하나만 들어가므로, 나머지 코드는 쏠브 담당자에게 알려주세요. 쏠브에서 연결해 드립니다. |
| 종이책 + 전자책 혼합 세트 | 전자책을 등록할 때 세트의 상품코드를 입력합니다. |
## B. 카탈로그 파일로 전달하기
쏠브가 전자책을 대신 등록하는 경우, 상품코드가 어떤 책인지 정리한 파일을 보내주세요. 엑셀·CSV·JSON 중 편한 형식이면 됩니다.
| 필드 | 필수 | 설명 |
| -------------- | -- | -------------------------------------------------------------------------------- |
| `item_code` | 필수 | 구매내역 API 의 `item_code` 와 같은 값 |
| `item_name` | 필수 | 상품명 |
| `isbn_list` | 권장 | 상품에 포함된 도서의 ISBN 목록. 쏠브 전자책과 매칭하는 핵심 키입니다. 패키지는 여러 개, 혼합 세트는 전자책 ISBN 만 적어도 됩니다. |
| `product_type` | 선택 | `book`, `package`, `lecture` 등 |
| `valid_period` | 선택 | 이용 기간 |
```csv title="catalog.csv"
item_code,item_name,product_type,isbn_list
BOOK_TOEIC_2026,토익 실전 2026,book,9791234567890
PKG_TOEIC_FULL,토익 풀패키지(LC+RC+해설),package,9791234567890;9791234567891;9791234567892
SET_TOEIC_PAPER_EBOOK,토익 실전 종이+전자 세트,package,9791234567890
```
```json title="catalog.json"
[
{ "item_code": "BOOK_TOEIC_2026", "item_name": "토익 실전 2026", "product_type": "book", "isbn_list": ["9791234567890"] },
{ "item_code": "PKG_TOEIC_FULL", "item_name": "토익 풀패키지(LC+RC+해설)", "product_type": "package", "isbn_list": ["9791234567890", "9791234567891", "9791234567892"] }
]
```
CSV 에서 여러 ISBN 은 `;` 로 구분합니다(다른 구분자도 협의 가능). 필드를 더 넣어도 괜찮고, 매칭은 쏠브가 책임지고 진행합니다.
* **출시 전**: 판매 중인 전자책 상품 전체를 한 번 보내주세요.
* **출시 후**: 신상품이 생길 때마다 해당 행을 추가해 다시 보내주세요.
## item\_code 규칙 (A·B 공통)
* **상품 단위**입니다. 책 단위가 아닙니다. 단품 A·B·C 와 이를 묶은 패키지 D 는 각각 다른 코드입니다.
* 같은 책이 여러 코드에 들어가도 괜찮습니다.
* 한 번 쓴 코드는 **의미를 바꾸거나 다른 상품에 다시 쓰지 마세요.** 과거 구매자의 책장이 엉뚱한 책으로 바뀝니다.
## 관련 문서
* [구매내역 API](https://developers.solve.im/docs/partner-integration/purchase-history-api) · [테스트와 출시](https://developers.solve.im/docs/partner-integration/onboarding)
# 구매내역 API
URL: https://developers.solve.im/docs/partner-integration/purchase-history-api
> 쏠브가 user_key 를 보내면 회원이 지금 이용할 수 있는 상품코드 목록을 돌려주는, 제휴사가 구현하는 API 입니다.
쏠브가 `user_key` 를 보내면, 그 회원이 **지금 이용할 수 있는** 상품의 `item_code` 목록을 돌려주는 server-to-server API 입니다. 쏠브는 이 목록을 보고 책장에 책을 넣고 뺍니다.
응답에는 **현재 유효한 구매만** 담아 주세요. 목록에서 빠진 상품은 쏠브 책장에서도 빠집니다(목록 전체가 빈 경우는 예외, 아래 표 참고). 구매 이력 전체가 아니라 "지금 이 순간 이용 가능한 것"을 돌려주는 API 입니다.
## 한눈에 보기
| 항목 | 내용 |
| ------- | -------------------------------------------------------- |
| 호출하는 쪽 | 쏠브 서버 → 제휴사 서버 |
| 메서드·URL | `POST`, URL 은 제휴사가 정합니다 |
| 형식 | `Content-Type: application/json` 요청, JSON 응답 |
| 인증 | 제휴사가 발급한 시크릿을 쏠브가 헤더에 담습니다. 헤더 이름과 방식은 제휴사가 정합니다. |
| 타임아웃 | 호출당 10초 |
| 재시도 | `5xx`·타임아웃이면 최대 2번 더 호출합니다. `4xx` 는 재시도하지 않습니다. |
| 호출 시점 | 연동 직후, 회원의 책장 진입, "전체 동기화" 버튼 ([개념과 용어](https://developers.solve.im/docs/partner-integration/overview)) |
| 페이지네이션 | 사용하지 않습니다. 유효한 항목을 한 번에 모두 돌려주세요. |
## 요청
```bash title="쏠브가 보내는 요청 (curl 로 재현)"
curl -X POST https://api.partner.example.com/solve/purchases \
-H 'Content-Type: application/json' \
-H 'X-Solve-Secret: <제휴사가 발급한 시크릿>' \
-d '{"user_key": "100023"}'
```
| 필드 | 타입 | 필수 | 설명 |
| ---------- | ------ | -- | --------------- |
| `user_key` | string | 필수 | 연동할 때 받은 회원 식별자 |
## 응답
### 성공 (200)
```json
{
"item_list": [
{ "item_code": "BOOK_TOEIC_2026", "item_name": "토익 실전 2026" },
{ "item_code": "PKG_TOEIC_FULL" }
]
}
```
| 필드 | 타입 | 필수 | 설명 |
| ------------------------- | ---------------- | -- | ----------------------------------------------------------------------------------------- |
| `item_list` | array | 필수 | 현재 유효한 구매 항목. 없으면 빈 배열 `[]` |
| `item_list[].item_code` | string | 필수 | 제휴사 상품코드. 파트너 어드민에 입력한 **연동코드**와 같은 값이어야 합니다. 책이 아니라 **상품 단위**이며, 한 코드에 여러 권이 들어갈 수 있습니다. |
| `item_list[].item_name` | string | 선택 | 표시·문의 대응용 상품명 |
| `item_list[].price` | number 또는 string | 선택 | 결제 금액(원). 아래 "선택 필드" 참고 |
| `item_list[].paid_at` | string | 선택 | 결제 시각. 아래 "선택 필드" 참고 |
| `item_list[].valid_from` | string | 선택 | 이용 시작. 아래 "선택 필드" 참고 |
| `item_list[].valid_until` | string | 선택 | 이용 종료. 아래 "선택 필드" 참고 |
위 표에 없는 다른 필드를 더 담아도 쏠브는 무시하므로 괜찮습니다.
**응답 본문은 1MB(1,048,576바이트) 이하여야 합니다.** 쏠브는 1MB 를 넘는 응답을 받지 않습니다. 넘으면 그 호출은 실패로 처리하고 재시도하지 않으며, **기존 책장은 유지**합니다. 압축해서 보내는 경우는 푼 뒤의 크기로 셉니다. `item_list` 에는 필요한 필드만 담아 주세요.
### 선택 필드 (v1.1)
항목마다 아래 네 필드를 **선택으로** 더 담을 수 있습니다. 쏠브는 정산과 문의 대응에 사용합니다.
| 필드 | 형식 |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `price` | 0 이상 정수(숫자) 또는 숫자만으로 된 문자열(1\~12자리). 원 단위입니다. |
| `paid_at` | ISO 8601 날짜·시각. `Z`·`+09:00` 같은 오프셋이 있으면 그대로 읽고, **오프셋이 없으면 한국 시간(+09:00)** 으로 읽습니다. 날짜만 보내면 그날 00:00 한국 시간입니다. |
| `valid_from`, `valid_until` | 1\~32자 문자열. 쏠브는 해석하지 않고 그대로 기록합니다. |
* 네 필드 모두 **없어도 동작합니다.** v1.0 대로 구현한 응답은 수정 없이 유효합니다.
* 형식이 틀리면 **그 필드만 무시**하고, 책 지급에는 영향이 없습니다.
* **`price` 를 안 보내거나 `0` 이면 파트너 어드민에 등록한 가격으로 정산합니다.**
* `valid_until` 이 지나도 책은 자동으로 빠지지 않습니다. 책을 회수하려면 지금처럼 **목록에서 빼 주세요.**
```json title="v1.1 예시 — 선택 필드를 모두 채운 항목과 item_code 만 있는 항목"
{
"item_list": [
{
"item_code": "BOOK_TOEIC_2026",
"item_name": "토익 실전 2026",
"price": 25000,
"paid_at": "2026-09-01T14:30:00+09:00",
"valid_from": "2026-09-01",
"valid_until": "2027-08-31"
},
{ "item_code": "PKG_TOEIC_FULL" }
]
}
```
### 상황별 응답
**빈 목록과 오류는 결과가 정반대**입니다. 이 표대로 응답해 주세요.
| 상황 | 응답 | 쏠브의 처리 |
| --------------------- | --------------------------- | ------------------------------------------- |
| 구매한 상품이 있음 | `200` + 유효한 항목 | 목록에 있는 책을 책장에 넣습니다 |
| 일부 상품이 환불·만료됨 | 해당 항목을 **목록에서 뺌** | 다음 동기화 때 그 책을 책장에서 뺍니다 |
| 구매한 상품이 없음 (전부 환불 포함) | `200` + `{"item_list": []}` | 새로 넣을 책이 없습니다. 이미 있는 책은 **빼지 않습니다** (아래 참고) |
| 회원이 없음 (탈퇴 등) | `200` + `{"item_list": []}` | 위와 같습니다 |
| 시크릿 불일치 | `401` 또는 `403` | 인증 실패로 기록하고 **기존 책장은 유지**합니다 |
| 요청 형식 오류 | `400` | 오류로 기록하고 **기존 책장은 유지**합니다 |
| 서버 오류, DB 장애, 점검 | `500`·`503` 등 `5xx` | 재시도하고, 실패해도 **기존 책장은 유지**합니다 |
쏠브는 `401` 과 `403` 을 구분하지 않고 같은 인증 실패로 처리합니다. 둘 다 재시도하지 않습니다.
**빈 목록일 때 책을 빼지 않는 이유**: 장애 중인 API 가 실수로 빈 목록을 돌려주면 회원 전원의 책이 사라질 수 있어, 쏠브는 목록이 통째로 비어 있으면 회수를 건너뜁니다. 그래서 회원의 **모든** 상품이 환불되거나 회원이 탈퇴한 경우에는 책이 책장에 남습니다. 이런 경우의 회수가 필요하면 쏠브 담당자와 처리 방법을 협의해 주세요.
예외를 삼키고 `200` 을 보내면 쏠브는 정상 응답으로 처리합니다. 특히 여러 저장소 중 일부만 조회된 **불완전한 목록**을 보내면, 빠진 상품이 회원 책장에서 회수됩니다. 조회가 하나라도 실패하면 `5xx` 로 응답해 주세요.
## 구현 예시
시크릿 확인 → 회원의 **현재 유효한** 구매 조회 → `item_list` 응답 순서입니다. 조회 함수는 제휴사 시스템에 맞게 바꾸세요.
Node.js
Python
Java
PHP
```js
import express from 'express';
import crypto from 'node:crypto';
const app = express();
app.use(express.json());
const SOLVE_SECRET = Buffer.from(process.env.SOLVE_SECRET ?? '');
// 시크릿이 비어 있으면 빈 헤더 요청이 통과하므로 서버를 띄우지 않는다
if (SOLVE_SECRET.length === 0) throw new Error('SOLVE_SECRET is not set');
function isFromSolve(req) {
const given = Buffer.from(req.get('X-Solve-Secret') ?? '');
return given.length === SOLVE_SECRET.length && crypto.timingSafeEqual(given, SOLVE_SECRET);
}
app.post('/solve/purchases', async (req, res) => {
if (!isFromSolve(req)) return res.status(403).json({ error: 'forbidden' });
const userKey = req.body?.user_key;
if (typeof userKey !== 'string' || userKey === '') {
return res.status(400).json({ error: 'invalid_request' });
}
try {
// 환불·만료를 뺀, 지금 이용 가능한 구매만 조회. 회원이 없으면 빈 배열.
const purchases = await findActivePurchases(userKey);
return res.json({ // [!code highlight]
item_list: purchases.map((p) => ({ item_code: p.productCode, item_name: p.productName })), // [!code highlight]
}); // [!code highlight]
} catch (err) {
// 조회 실패를 200 으로 보내면 불완전한 목록 기준으로 책이 회수될 수 있다 → 반드시 5xx
console.error('solve purchases failed', err);
return res.status(500).json({ error: 'internal_error' }); // [!code highlight]
}
});
```
```python
import hmac
import logging
import os
from fastapi import FastAPI, Header
from fastapi.responses import JSONResponse
from pydantic import BaseModel
app = FastAPI()
logger = logging.getLogger(__name__)
SOLVE_SECRET = os.environ["SOLVE_SECRET"]
class PurchasesRequest(BaseModel):
user_key: str
@app.post("/solve/purchases")
def purchases(body: PurchasesRequest, x_solve_secret: str = Header(default="")):
if not hmac.compare_digest(x_solve_secret, SOLVE_SECRET):
return JSONResponse({"error": "forbidden"}, status_code=403)
try:
# 환불·만료를 뺀, 지금 이용 가능한 구매만 조회. 회원이 없으면 빈 리스트.
rows = find_active_purchases(body.user_key)
except Exception:
# 조회 실패를 200 으로 보내면 불완전한 목록 기준으로 책이 회수될 수 있다 → 반드시 5xx
logger.exception("solve purchases failed")
return JSONResponse({"error": "internal_error"}, status_code=500) # [!code highlight]
return {"item_list": [{"item_code": r.product_code, "item_name": r.product_name} for r in rows]} # [!code highlight]
```
```java
@RestController
@RequestMapping("/solve")
public class SolvePurchaseController {
private static final Logger log = LoggerFactory.getLogger(SolvePurchaseController.class);
@Value("${solve.secret}")
private String solveSecret;
private final PurchaseService purchaseService;
public SolvePurchaseController(PurchaseService purchaseService) {
this.purchaseService = purchaseService;
}
public record PurchasesRequest(String user_key) {}
@PostMapping("/purchases")
public ResponseEntity
```php
'forbidden']);
exit;
}
$body = json_decode(file_get_contents('php://input'), true);
$userKey = $body['user_key'] ?? null;
if (!is_string($userKey) || $userKey === '') {
http_response_code(400);
echo json_encode(['error' => 'invalid_request']);
exit;
}
try {
// 환불·만료를 뺀, 지금 이용 가능한 구매만 조회. 회원이 없으면 빈 배열.
$rows = find_active_purchases($userKey);
} catch (Throwable $e) {
// 조회 실패를 200 으로 보내면 불완전한 목록 기준으로 책이 회수될 수 있다 → 반드시 5xx
error_log('solve purchases failed: ' . $e->getMessage());
http_response_code(500); // [!code highlight]
echo json_encode(['error' => 'internal_error']);
exit;
}
echo json_encode([
'item_list' => array_map(
fn ($r) => ['item_code' => $r['product_code'], 'item_name' => $r['product_name']],
$rows
),
], JSON_UNESCAPED_UNICODE);
```
## 흐름
## 구현 체크리스트
* [ ] 환불·만료된 상품을 목록에서 뺀다
* [ ] 구매가 없거나 회원이 없으면 `200` + 빈 배열로 응답한다
* [ ] 조회 실패·장애는 `5xx` 로 응답한다 (빈 배열·불완전한 목록 금지)
* [ ] `item_code` 가 파트너 어드민에 입력한 연동코드와 같다 (쏠브는 앞뒤 공백을 떼고 비교하고, 그 밖은 대소문자까지 글자 그대로 같아야 한다)
* [ ] 10초 안에 응답한다
* [ ] 구매 항목을 페이지 나누지 않고 한 번에 돌려준다
* [ ] 응답 본문을 1MB(1,048,576바이트) 이하로 만든다
## 관련 문서
* [상품 연결](https://developers.solve.im/docs/partner-integration/product-catalog) · [보안 요구사항](https://developers.solve.im/docs/partner-integration/security) · [테스트와 출시](https://developers.solve.im/docs/partner-integration/onboarding)
# 구현 순서
URL: https://developers.solve.im/docs/partner-integration/quickstart
> 로그인 방식 선택부터 출시까지, 제휴사가 할 일을 5단계로 정리했습니다.
처음 연동하는 제휴사 개발자를 위한 단계별 안내입니다. 각 단계의 상세 규약은 링크된 페이지에 있습니다.
[AI로 구현하기](https://developers.solve.im/docs/partner-integration/ai-guide)의 통합 프롬프트를 먼저 복사해 두세요. 아래 단계를 AI 가 같은 기준으로 구현합니다.
### 로그인 방식 고르기
회원 인증 방식에 따라 하나를 고릅니다. 기준은 [개념과 용어](https://developers.solve.im/docs/partner-integration/overview#로그인-방식-고르기)에 있습니다.
| 우리 사이트는… | 선택 |
| ----------------------------------------------------- | -------------------------------------------- |
| 자체 ID/PW 로그인**만** 운영하고, ID/PW 를 검증하는 서버 API 를 만들 수 있다 | [ⓐ 자격증명 로그인 API](https://developers.solve.im/docs/partner-integration/login-credential-api) |
| 카카오·네이버·구글·Apple 같은 소셜 로그인을 **함께** 운영한다 | [ⓑ 호스팅 로그인 페이지](https://developers.solve.im/docs/partner-integration/login-hosted-page) |
ⓑ 를 고르면 쏠브가 콜백 API URL, `platform_type` 값, 콜백 인증 시크릿을 전달해 드립니다.
### 로그인 구현하기
로그인에 성공하면 회원의 **불변 식별자**(`user_key`)를 쏠브에 넘겨줍니다. 로그인 아이디나 이메일처럼 바뀔 수 있는 값이 아니라, 내부 회원번호처럼 절대 바뀌지 않는 값을 쓰세요.
* ⓐ: 쏠브가 보낸 ID/PW 를 검증하고 `{ "user_key": "..." }` 로 응답합니다.
* ⓑ: 제휴사 페이지에서 로그인시킨 뒤, 서버에서 쏠브 콜백을 호출하고 받은 `redirect_url` 로 이동시킵니다.
### 구매내역 API 구현하기
쏠브가 `user_key` 를 보내면 **지금 이용할 수 있는** 상품코드 목록을 돌려줍니다. 상세는 [구매내역 API](https://developers.solve.im/docs/partner-integration/purchase-history-api)를 보세요.
```json title="응답 예시"
{
"item_list": [
{ "item_code": "BOOK_TOEIC_2026", "item_name": "토익 실전 2026" }
]
}
```
* 환불·만료된 상품은 목록에서 빼주세요. 쏠브 책장에서도 빠집니다.
* 회원이 없으면 오류가 아니라 **빈 목록(200)** 으로 응답해 주세요.
### 전자책 등록하기 (연동코드 입력)
쏠브 파트너 어드민에서 전자책 PDF 를 등록할 때 판매유형을 `연동형` 또는 `연동+쏠브북스`로 고르고, **연동코드**란에 구매내역 API 가 돌려주는 `item_code` 와 같은 값을 입력합니다. 그러면 그 상품을 구매한 회원의 책장에 책이 자동으로 들어갑니다. 상세는 [상품 연결](https://developers.solve.im/docs/partner-integration/product-catalog)을 보세요.
### 셀프 점검 후 테스트 자료 전달하기
[테스트와 출시](https://developers.solve.im/docs/partner-integration/onboarding)의 셀프 점검 스크립트로 먼저 확인한 뒤, 아래 자료를 쏠브에 보내주세요. 구매내역 API 와 ⓐ 로그인 API 는 전체 점검 도구(`solve-selfcheck.mjs`)로, ⓑ 방식의 콜백은 [콜백 흉내 도구](https://developers.solve.im/docs/partner-integration/login-hosted-page)로 미리 시험할 수 있습니다. 쏠브가 실제 호출과 앱 화면으로 검증하고 출시 일정을 잡습니다.
* 로그인 URL(ⓐ API 또는 ⓑ 로그인 페이지)과 구매내역 API URL
* 구매 내역이 있는 테스트 계정
* 그 계정이 보유한 `item_code` 와 상품명
## 다음 단계
* 구매한 회원을 쏠브 앱으로 안내하는 방법: [구매 후 사용자 안내](https://developers.solve.im/docs/partner-integration/user-discovery)
* 자주 묻는 질문: [FAQ](https://developers.solve.im/docs/partner-integration/faq)
# 보안 요구사항
URL: https://developers.solve.im/docs/partner-integration/security
> 전송·인증·식별자·세션 등 연동 전 구간에 적용되는 보안 항목입니다.
연동의 모든 구간에 적용되는 보안 항목입니다. 출시 전에 꼭 확인해 주세요.
| 항목 | 요구사항 |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 전송 | 모든 API·콜백·페이지는 HTTPS(TLS)만 사용합니다. |
| 서버 간 인증 | 쏠브는 **제휴사가 발급한 시크릿**을 헤더에 담아 호출합니다. 제휴사는 상수 시간 비교로 확인하고, 쏠브는 시크릿을 안전하게 보관하며 로그에 남기지 않습니다. |
| `user_key` | 한 번 정한 값은 절대 바뀌지 않고, 회원끼리 겹치지 않아야 합니다. 탈퇴 회원의 값을 다시 쓰면 안 됩니다. |
| 자격증명 | ⓐ 에서는 쏠브가 ID/PW 를 TLS 서버 간 호출로만 전달하고 로그에 남기지 않습니다. ⓑ 에서는 ID/PW 가 쏠브로 전달되지 않습니다. |
| ⓑ 콜백 | 로그인에 **성공한 경우에만** 제휴사 **서버**에서, 쏠브가 발급한 콜백 인증 시크릿(`Authorization: Bearer`)을 담아 호출합니다. |
| ⓑ 세션 | 쏠브는 연동을 시작한 웹뷰에 1회용 토큰을 발급해 재생 공격·CSRF 를 막고, `redirect_url` 에 만료 시간이 있는 서명을 담아 회원 식별자 위변조를 막습니다. `redirect_url` 은 수정하지 말고 같은 웹뷰에서 여세요. 제휴사가 `state`·nonce 를 따로 구현할 필요는 없습니다. |
| 호출량 | rate limit 은 두지 않아도 됩니다. 둔다면 **분당 600회(초당 10회) 이상**으로 잡아 주세요. 쏠브는 책장 진입·전체 동기화 때 몰아서 호출하고, 일시 장애 때 재시도합니다. |
| 시크릿 회전 | **제휴사가 발급한 API 시크릿**: 바꾸실 때는 미리 알려 주세요. **새 값과 옛 값을 함께 받아 주시는 동안** 쏠브가 새 값으로 바꿉니다(반영까지 최대 5분). 바뀐 것을 확인하신 뒤 옛 값을 폐기하시면 됩니다.
**쏠브가 발급한 콜백 인증 시크릿**: 쏠브가 새 값을 드리고, **일정 기간 새 값과 옛 값을 함께 받습니다.** 그 기간 안에 새 값으로 바꿔 주세요. |
## 같은 제휴사 계정을 다른 쏠브 계정에 연동하면
이미 다른 쏠브 사용자에게 연동된 제휴사 회원이 새로 연동하면, 이전 연동을 해제하고 새 사용자에게 옮깁니다. 제휴사는 `user_key` 의 불변·유일성만 지키면 되고, 이전 처리는 쏠브가 합니다.
## 관련 문서
* [ⓐ 자격증명 로그인 API](https://developers.solve.im/docs/partner-integration/login-credential-api) · [ⓑ 호스팅 로그인 페이지](https://developers.solve.im/docs/partner-integration/login-hosted-page) · [구매내역 API](https://developers.solve.im/docs/partner-integration/purchase-history-api)
# 구매 후 사용자 안내
URL: https://developers.solve.im/docs/partner-integration/user-discovery
> 구매한 회원이 쏠브 앱에 도달하도록 안내하는 방법
# 구매 후 사용자 안내
이 연동은 **사용자가 쏠브 앱 안에서 직접 제휴사 계정을 연동**하는 모델입니다. 제휴사 사이트에서 결제가 끝난 뒤의 다음 단계는 사용자가 쏠브 앱에서 진행하므로, 회원이 쏠브 앱의 존재를 인지하도록 안내해 주시면 실제 연동률이 크게 올라갑니다.
> 이 연동 모델에서 **쏠브는 구매 회원에게 문자(SMS)를 발송하지 않습니다.** 쏠브가 주문 정보를 직접 수신하는 일부 커머스 채널과 달리, 이 모델은 사용자가 앱에서 스스로 연동을 시작하는 구조이기 때문입니다. 제휴사 측의 SMS 자동화도 필수가 아닙니다.
## 사용자가 구매 후 책을 보는 흐름
1. 제휴사 사이트에서 전자책·강의를 구매합니다.
2. 쏠브 앱을 실행합니다 (없으면 앱스토어에서 설치, 최초 1회).
3. 앱 안에서 "제휴사 연동하기"로 진입합니다.
4. 제휴사 계정으로 로그인하면 연동이 완료됩니다.
5. 쏠브가 구매내역 API 를 자동으로 호출합니다 (백그라운드).
6. 책장에 구매한 전자책이 자동 등록·표시됩니다.
7. 책을 선택해 학습을 시작합니다.
## 안내 방법
아래 두 옵션은 모두 선택 사항이며, 필수 구현 범위에 포함되지 않습니다. 하나만 선택하셔도 되고 병행하셔도 됩니다.
### 옵션 A. 제휴사 사이트 내 안내 (권장)
사이트 안에 "쏠브 앱에서 학습 가능" 안내와 진입점(버튼·배너)을 노출하는 방식입니다. 별도 시스템 구축 없이 사용자가 자연스럽게 인지할 수 있습니다.
* **권장 위치** — 결제 완료 페이지가 가장 효과적입니다. 마이페이지·구매내역 페이지도 좋습니다.
* **선택 위치** — 전자책 상품 상세 페이지의 안내 배지.
* 쏠브가 알려 드리는 웹 연동 주소를 걸어 두면, 클릭 시 쏠브북스 웹의 "제휴사 연동" 화면이 열립니다. 쏠브북스에 로그인하면 연동 창이 바로 뜹니다. 주소 형식은 `https://books.solve.im/mypage/partner-connect?partner=<제휴사 이름>` 이고, `partner` 값은 계약 시 알려 드립니다.
### 옵션 B. 제휴사 자체 알림 채널 (선택)
기존에 운영 중인 알림톡·이메일 등에 안내 문구를 추가하는 방식입니다. 쏠브가 강제하는 형식은 없으며, 아래 요소를 포함해 주시면 좋습니다.
* 구매한 상품을 쏠브 앱에서 학습할 수 있다는 안내와 앱 다운로드 링크
* 앱 실행 후 "제휴사 연동하기"에서 로그인하면 책장에 자동 추가된다는 안내
예시 문구 — "구매하신 \[상품명]은 쏠브 앱에서 학습하실 수 있습니다. \[앱 다운로드 링크] 앱 실행 후 '제휴사 연동'에서 로그인하시면 책장에 자동으로 추가됩니다."
## 제공 자료
아래 자료는 계약 시 쏠브 담당자가 별도로 전달합니다.
* 웹 연동 주소 — 클릭 시 쏠브북스 웹의 제휴사 연동 화면이 열립니다 (쏠브북스 로그인 필요)
* 앱 설치 링크 — 클릭 시 스토어로 이동
* BI 가이드 — 로고 파일, 권장 카피, 색상·버튼 규격 (제휴사 사이트의 톤앤매너에 맞춰 조정하셔도 무방합니다)
## 관련 문서
* [제휴사 연동 개요](https://developers.solve.im/docs/partner-integration) · [테스트와 출시](https://developers.solve.im/docs/partner-integration/onboarding)