주문과 마이페이지
이 페이지의 API는 모두 사용자 토큰이 필요합니다.
외부 정적 스토어프론트의 마이페이지에서는 프로필, 주문 목록, 주문 상세, 보유 콘텐츠를 브라우저용 스토어프론트 키와 사용자 토큰으로 조회합니다.
기본 fetch wrapper
섹션 제목: “기본 fetch wrapper”마이페이지 API는 항상 X-Runmoa-Site-Key와 Authorization을 함께 보냅니다.
const API_BASE = 'https://your-site.runmoa.com/api/storefront/v1';const STOREFRONT_KEY = 'moa_pub_xxxxxxxxx';const token = localStorage.getItem('runmoa_user_token');
async function storefrontMe(path) { const response = await fetch(`${API_BASE}${path}`, { credentials: 'include', headers: { 'X-Runmoa-Site-Key': STOREFRONT_KEY, Authorization: `Bearer ${token}`, Accept: 'application/json', }, });
if (!response.ok) { throw new Error(`Runmoa API ${response.status}`); }
return response.json();}내 정보
섹션 제목: “내 정보”| 메서드 | 경로 | 설명 |
|---|---|---|
| GET | /me | 내 정보 조회 |
| GET | /me/access-context | 사용자 접근 권한 컨텍스트 조회 |
const me = await storefrontMe('/me');
// callback 응답의 user는 최소 정보일 수 있으므로,// 로그인 직후에도 /me를 다시 호출해서 이름, 이메일, 전화번호를 표시합니다.const user = me.user;주문 조회
섹션 제목: “주문 조회”| 메서드 | 경로 | 설명 |
|---|---|---|
| GET | /me/orders | 내 주문 목록 |
| GET | /me/orders/{order_id} | 내 주문 상세 |
| GET | /me/orders/{order_id}/details | 내 주문 상세 항목 |
| GET | /me/order-lines | 상품/콘텐츠 주문 항목 통합 목록 |
| GET | /me/order-lines/{line_item_id} | 상품/콘텐츠 주문 항목 통합 상세 |
마이페이지 첫 화면은 GET /me/orders로 주문 단위 목록을 보여줍니다.
const orders = await storefrontMe('/me/orders?page=1&limit=20');대표 주문 목록 응답:
{ "orders": { "data": [ { "order_id": 12837, "order_number": "RM-20260622-12837", "status": "paid", "price": 49000, "price_total": 49000, "created_at": "2026-06-22T09:30:00+09:00", "paid_date": "2026-06-22T09:31:00+09:00" } ], "total": 1, "current_page": 1 }}const orderId = 12837;
const [summary, details] = await Promise.all([ storefrontMe(`/me/orders/${order_id}`), storefrontMe(`/me/orders/${order_id}/details`),]);대표 주문 상세 응답:
{ "order": { "order_id": 12837, "order_number": "RM-20260622-12837", "status": "paid", "price_total": 49000, "receiver_name": "홍길동", "receiver_phone": "01012345678", "address": "서울시 강남구 테헤란로 123", "address_detailed": "101호", "postal_code": "06234" }}주문 항목 통합 API
섹션 제목: “주문 항목 통합 API”상품과 콘텐츠를 같은 UI 구조로 보여주려면 GET /me/order-lines를 사용합니다.
const orderLines = await storefrontMe('/me/order-lines?page=1&limit=20');line_item_id는 주문 항목을 가리키는 문자열입니다. 프론트엔드는 값을 분해하지 말고 그대로 다음 API에 전달합니다.
대표 응답:
{ "line_items": [ { "line_item_id": "product:77", "kind": "product", "content_type": null, "order_id": 12837, "lookup_id": 77, "type": "product", "product_id": 12001, "variant_id": 88421, "title": "러닝 재킷", "subtitle": "Black / M", "quantity": 1, "price": 49000, "shipping_price": 3000, "image_url": "https://cdn.example.com/products/running-jacket.jpg", "status": "delivered", "serve": { "mode": "none" }, "actions": [ { "type": "exchange_request", "enabled": true, "label": "교환요청", "method": "POST" }, { "type": "return_request", "enabled": true, "label": "반품요청", "method": "POST" }, { "type": "confirm_purchase", "enabled": true, "label": "구매확정", "method": "POST" } ], "review": { "exists": false, "review_id": null } } ], "total": 1, "current_page": 1, "per_page": 20}콘텐츠 주문 항목은 같은 배열 안에 아래처럼 들어올 수 있습니다.
{ "line_item_id": "content:99031", "kind": "content", "content_type": "vod", "order_id": 12838, "lookup_id": 99031, "content_id": 6401, "option_id": 55102, "curriculum_id": 91201, "title": "브랜드 포지셔닝 워크숍", "subtitle": "1강. 브랜드 핵심 메시지 정리", "quantity": 1, "price": 99000, "shipping_price": 0, "image_url": "https://cdn.example.com/contents/brand-positioning-thumb.jpg", "status": "paid", "access": { "state": "available", "watchable": true, "reason": null }, "serve": { "mode": "internal_player" }, "actions": [ { "type": "open", "enabled": true, "label": "콘텐츠 열기", "method": "POST" }, { "type": "confirm_purchase", "enabled": true, "label": "구매확정", "method": "POST" }, { "type": "refund_request", "enabled": true, "label": "환불 요청", "method": "POST" } ], "review": { "exists": false, "review_id": null, "targets": [ { "curriculum_id": 91201, "title": "1강. 브랜드 핵심 메시지 정리" } ] }}상품과 콘텐츠 모두 버튼은 actions[].enabled === true인 값만 표시합니다. line_item_id는 product:77, content:99031 같은 문자열 그대로 다음 API path에 넣습니다. open 버튼은 POST /me/content-entitlements/{line_item_id}/open을 호출하고, 구매확정/취소/환불/반품/교환 버튼은 POST /me/order-lines/{line_item_id}/actions를 호출합니다.
특정 주문 항목 상세는 같은 line_item_id로 조회합니다.
const line = await storefrontMe('/me/order-lines/product:77');내 콘텐츠와 커리큘럼
섹션 제목: “내 콘텐츠와 커리큘럼”| 메서드 | 경로 | 설명 |
|---|---|---|
| GET | /me/content-counts/{type} | 내 콘텐츠 수량 |
| GET | /me/contents/{type}/{status} | 내 콘텐츠 목록 |
| GET | /me/content-items/{content_id} | 구매한 콘텐츠 상세/수강 데이터 |
| GET | /me/content-items/{content_id}/progress | 구매한 콘텐츠 진행률/시청 만료 정보 |
type과 status는 임의의 화면 라벨이 아니라 아래 path 값입니다. 먼저 GET /me/content-counts/all로 유형별 상태 수를 받고, 그 값을 탭 숫자와 조회 URL에 사용하면 됩니다.
| 값 | 의미 |
|---|---|
type=all | 주문으로 보유한 전체 콘텐츠 유형. 멤버십 전용 목록은 별도 membership 조회입니다. |
type=live | 라이브 콘텐츠 |
type=offline | 오프라인 콘텐츠 |
type=vod | VOD 콘텐츠 |
type=digital_content | 디지털 콘텐츠 |
type=membership | 현재 사용자의 멤버십으로 접근 가능한 VOD 목록 |
status=all | 상태 필터 없음 |
status=pending | 아직 진행 전 |
status=processing | 진행 중 |
status=completed | 완료 |
유형별 카운트 응답은 content_counts[type][status]입니다. membership은 별도 권한 기준으로 집계되며, 현재 멤버십 목록은 상태별로 나뉘지 않습니다. VOD는 진행률을 기준으로 카운트 상태를 계산합니다: 0%는 pending, 0% 초과~80%는 processing, 80% 초과는 completed. 일반 라이브/오프라인 콘텐츠는 수강 상태를 기준으로 집계합니다.
const counts = await storefrontMe('/me/content-counts/all');const vodInProgress = counts.content_counts.vod.processing;목록은 /me/contents/{type}/{status}로 조회합니다. content_id에는 목록 결과의 content_id를 사용합니다. curriculum_id(회차 ID)나 주문 항목의 line_item_id를 넣지 마세요.
const ownedContents = await storefrontMe('/me/contents/all/all?page=1&limit=20');const rows = ownedContents.contents.data;목록은 페이지네이션 객체 contents를 반환하며, 각 콘텐츠는 contents.data[]에 들어옵니다. 예시:
{ "contents": { "current_page": 1, "data": [ { "content_id": 6401, "title": "브랜드 포지셔닝 워크숍", "content_type": "vod", "status": "processing", "progress_rate": 42, "watchable": true, "curriculums": [ { "curriculum_id": 91201, "title": "1강. 브랜드 핵심 메시지 정리", "media_url": "https://customer-<CODE>.cloudflarestream.com/<VIDEO_UID>/watch" } ] } ], "last_page": 1, "total": 1, "vodCounts": 1, "membershipCounts": 0 }}빈 결과도 같은 contents.data 배열을 반환합니다. 카운트 API는 content_counts[type][status]로 읽습니다. 목록의 기존 total과 유형별 보조 카운트는 필터 결과 건수와 다를 수 있으므로 탭 숫자에는 카운트 API를 사용하세요.
참고: VOD의 목록 필터는 pending을 진행률 0%로 찾지만 목록 행의 status 값은 0%일 때도 processing으로 표시될 수 있습니다. 탭의 기준은 /me/content-counts/{type} 응답을 따르고, 상세 학습 상태가 필요하면 진행률 API를 사용하세요.
구매 후 콘텐츠 상세 표시
섹션 제목: “구매 후 콘텐츠 상세 표시”콘텐츠 판매 스토어를 만들 때는 목록만으로 끝나지 않습니다. 사용자가 구매한 뒤 실제로 들어가는 화면은 GET /me/content-items/{content_id}를 기준으로 구현합니다.
const lesson = await storefrontMe('/me/content-items/6401');이 응답은 공개 상세와 달리 “내 구매/접근 권한 기준” 데이터를 내려줍니다. content_id는 보유 목록의 content_id(예: 6401)입니다. 즉 아래 같은 필드가 붙을 수 있습니다.
curriculums: 내가 접근 가능한 실제 커리큘럼/회차 목록chapters: VOD 챕터/영상 목록watchable: 지금 재생 가능한지hold_blocked: 일시정지 상태 때문에 재생이 막혀 있는지content: 원본 콘텐츠 기본 정보
curriculums[]는 회차를 일렬로 표시할 때, chapters[].items[]는 챕터 구조를 유지할 때 사용합니다. 각 VOD 회차의 media_url은 저장된 재생 소스이며, Cloudflare Stream을 사용하는 경우 보통 .../<VIDEO_UID>/watch 형태입니다. reference_url은 별도로 입력한 관련 링크이므로 영상 재생 주소와 혼동하지 마세요.
대표 응답 예시:
{ "curriculums": [ { "curriculum_id": 91201, "title": "1강. 브랜드 핵심 메시지 정리", "option_id": 55102, "content_id": 6401, "content_type": "vod", "lookup_id": 99031, "status": "processing", "progress_rate": 42, "last_viewed_date": "2026-06-23 18:30:00", "resume_position": 318, "download_duration": 90, "media_url": "https://customer-<CODE>.cloudflarestream.com/<VIDEO_UID>/watch", "reference_url": null } ], "chapters": [ { "chapter_id": 71, "title": "브랜드 전략", "items": [ { "curriculum_id": 91201, "title": "1강. 브랜드 핵심 메시지 정리", "paid": "1", "media_url": "https://customer-<CODE>.cloudflarestream.com/<VIDEO_UID>/watch", "reference_url": null } ] } ], "watchable": true, "hold_blocked": false, "content": { "content_id": 6401, "title": "브랜드 포지셔닝 워크숍" }}공개 상세 GET /contents/{content_id}와 구매 후 상세 GET /me/content-items/{content_id}는 같은 화면 모델로 쓰지 않습니다. 공개 상세는 “판매용”, 구매 후 상세는 “학습/다운로드용”입니다.
외부 사이트에서 VOD 재생
섹션 제목: “외부 사이트에서 VOD 재생”현재 스토어프론트에는 Cloudflare Stream URL이나 서명 토큰을 새로 발급하는 전용 API가 없습니다. 외부 플레이어 연동 시에는 이 상세 응답의 회차 media_url에 저장된 소스를 사용하세요. 아래 예시는 Cloudflare Stream이 아닌 회차에서는 별도 처리가 필요합니다.
const lesson = await storefrontMe('/me/content-items/6401');const curriculums = lesson.chapters?.length ? lesson.chapters.flatMap((chapter) => chapter.items ?? []) : lesson.curriculums ?? [];const playbackSource = curriculums.find((item) => Number(item.curriculum_id) === 91201)?.media_url;Cloudflare Stream의 일반 .../<VIDEO_UID>/watch URL이라면 Cloudflare Player iframe 주소는 .../<VIDEO_UID>/iframe입니다. 예시는 Cloudflare Stream URL이고, 서명 토큰이 필요한 영상에는 그대로 적용할 수 없습니다.
const streamUrl = new URL(playbackSource);if (!streamUrl.hostname.endsWith('.cloudflarestream.com') || !streamUrl.pathname.endsWith('/watch')) { throw new Error('Cloudflare Stream watch URL이 아닙니다.');}streamUrl.pathname = streamUrl.pathname.replace(/\/watch$/, '/iframe');const iframeUrl = streamUrl.toString();<iframe src="https://customer-<CODE>.cloudflarestream.com/<VIDEO_UID>/iframe" title="강의 영상" allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;" allowfullscreen></iframe>직접 HLS 플레이어를 쓸 경우 Cloudflare가 안내하는 .../<VIDEO_UID>/manifest/video.m3u8 매니페스트를 사용합니다. 매니페스트는 동적으로 바뀔 수 있으므로 저장하거나 캐시하지 말고 Stream에서 직접 읽으세요. 외부 도메인 재생이 막히면 영상의 Cloudflare allowedOrigins 설정도 확인하세요. 자세한 형식은 Cloudflare Stream Player 문서, 자체 플레이어 문서, 보안 및 허용 도메인 설정을 참고하세요.
POST /me/content-entitlements/{line_item_id}/open은 런모아 내부 플레이어용 serve.url을 반환하는 API이지, Cloudflare Stream URL/토큰 발급 API가 아닙니다. Cloudflare 영상이 서명 URL을 요구하도록 설정되어 있으면 UID만 든 watch 경로로는 재생할 수 없습니다. 이 경우 영상 권한 확인 후 서버에서 짧은 수명의 서명 토큰을 발급하는 별도 연동이 필요하며, Cloudflare API 비밀키를 브라우저에 넣으면 안 됩니다. Cloudflare 서명 URL 안내를 참고하세요.
일시정지로 재생이 막힌 응답은 hold_blocked: true이며 VOD 회차의 media_url가 빈 문자열일 수 있습니다. watchable과 hold_blocked를 확인한 뒤 재생 UI를 표시하세요.
구매 후 콘텐츠 열기
섹션 제목: “구매 후 콘텐츠 열기”구매한 콘텐츠를 실제로 보여줄 때는 line_item_id를 사용합니다.
| 메서드 | 경로 | 설명 |
|---|---|---|
| POST | /me/content-entitlements/{line_item_id}/open | 구매한 콘텐츠 열기 |
VOD 콘텐츠는 런모아 플레이어 URL을 반환합니다.
const result = await fetch(`${API_BASE}/me/content-entitlements/content:99031/open`, { method: 'POST', headers: { 'X-Runmoa-Site-Key': STOREFRONT_KEY, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json', },}).then((response) => response.json());
window.location.href = result.serve.url;대표 VOD 응답:
{ "line_item": { "line_item_id": "content:99031", "kind": "content", "content_type": "vod", "title": "브랜드 포지셔닝 워크숍" }, "serve": { "mode": "internal_player", "url": "https://your-site.runmoa.com/my-page/media-player?class_id=6401&curriculum_id=91201" }}디지털 콘텐츠는 먼저 파일 정보를 조회합니다.
const info = await fetch(`${API_BASE}/me/content-entitlements/content:99032/open`, { method: 'POST', headers: { 'X-Runmoa-Site-Key': STOREFRONT_KEY, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json', },}).then((response) => response.json());다운로드 URL이 필요할 때는 issue_urls: true를 보냅니다. URL은 짧은 시간만 유효하므로 버튼을 누를 때마다 새로 발급합니다.
const download = await fetch(`${API_BASE}/me/content-entitlements/content:99032/open`, { method: 'POST', headers: { 'X-Runmoa-Site-Key': STOREFRONT_KEY, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ issue_urls: true }),}).then((response) => response.json());대표 디지털 파일 정보 응답:
{ "line_item": { "line_item_id": "content:99032", "kind": "content", "content_type": "digital_content", "title": "인스타그램 콘텐츠 캘린더 기획 서비스" }, "serve": { "mode": "download" }, "files": [ { "file_id": 8801, "name": "instagram-calendar-template.pdf", "size": 2480000, "expires_at": null } ]}대표 다운로드 URL 발급 응답:
{ "serve": { "mode": "download", "expires_in": 300 }, "urls": [ { "file_id": 8801, "url": "https://download.example.com/runmoa/signed-token", "expires_at": "2026-06-25T18:30:00+09:00" } ]}주문 항목 액션
섹션 제목: “주문 항목 액션”상품 주문 취소, 교환/반품 요청, 구매확정, 콘텐츠 환불 요청, 콘텐츠 구매확정은 같은 액션 API를 사용합니다.
| 메서드 | 경로 | 설명 |
|---|---|---|
| POST | /me/order-lines/{line_item_id}/actions | 주문 항목 액션 실행 |
프론트엔드는 먼저 line_item.actions에서 enabled: true인 액션만 버튼으로 보여줍니다.
await fetch(`${API_BASE}/me/order-lines/product:77/actions`, { method: 'POST', headers: { 'X-Runmoa-Site-Key': STOREFRONT_KEY, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json', },body: JSON.stringify({ type: 'confirm_purchase' }),});대표 액션 응답은 다시 line_item을 반환합니다. 화면은 응답의 status, actions, review를 다시 반영합니다.
{ "line_item": { "line_item_id": "product:77", "kind": "product", "status": "purchase_confirmed", "title": "러닝 재킷", "actions": [ { "type": "review_create", "enabled": true, "label": "후기 작성", "method": "POST" } ], "review": { "exists": false, "review_id": null } }}사용 가능한 type:
| type | 대상 | 설명 |
|---|---|---|
cancel_request | 상품 | 배송 전 주문 취소 요청 |
exchange_request | 상품 | 교환 요청 |
return_request | 상품 | 반품 요청 |
confirm_purchase | 상품/콘텐츠 | 구매확정 |
refund_request | 콘텐츠 | 환불 또는 취소 요청 |
취소/환불/반품/교환 요청에는 사유를 함께 보낼 수 있습니다.
{ "type": "refund_request", "title": "환불 요청", "description": "수강 일정이 맞지 않습니다."}후기 작성
섹션 제목: “후기 작성”상품 후기와 콘텐츠 후기는 같은 경로를 사용합니다.
| 메서드 | 경로 | 설명 |
|---|---|---|
| POST | /me/order-lines/{line_item_id}/review | 주문 항목 후기 작성 또는 수정 |
후기는 line_item.actions에 review_create 또는 review_update가 enabled: true일 때만 표시합니다.
await fetch(`${API_BASE}/me/order-lines/content:99031/review`, { method: 'POST', headers: { 'X-Runmoa-Site-Key': STOREFRONT_KEY, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ rating: 5, content: '실무에 바로 적용하기 좋았습니다.' }),});VOD처럼 후기 대상 커리큘럼을 사용자가 선택해야 하는 화면에서는 curriculum_id를 함께 보냅니다.
진행률과 다운로드
섹션 제목: “진행률과 다운로드”VOD나 디지털 콘텐츠는 보유 목록만 보여주는 것으로 끝나지 않습니다. 진행률 표시와 다운로드도 별도 API가 있습니다.
const progress = await storefrontMe('/me/content-items/6401/progress');대표 진행률 응답:
{ "lookup": { "lookup_id": 99031, "progress_rate": 42, "view_expire_at": "2026-09-21 23:59:59", "hold_limit": 3, "hold_used": 1, "hold_active": 0 }}디지털 파일이 있는 콘텐츠도 주문 항목의 line_item_id로 엽니다. 파일 목록은 POST /me/content-entitlements/{line_item_id}/open으로 조회하고, 실제 다운로드 URL이 필요하면 같은 경로에 issue_urls: true를 보냅니다.
const info = await fetch(`${API_BASE}/me/content-entitlements/content:99032/open`, { method: 'POST', headers: { 'X-Runmoa-Site-Key': STOREFRONT_KEY, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json', }}).then((response) => response.json());const download = await fetch(`${API_BASE}/me/content-entitlements/content:99032/open`, { method: 'POST', headers: { 'X-Runmoa-Site-Key': STOREFRONT_KEY, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ issue_urls: true, }),}).then((response) => response.json());