콘텐츠로 이동

주문과 마이페이지

이 페이지의 API는 모두 사용자 토큰이 필요합니다.

외부 정적 스토어프론트의 마이페이지에서는 프로필, 주문 목록, 주문 상세, 보유 콘텐츠를 브라우저용 스토어프론트 키와 사용자 토큰으로 조회합니다.

마이페이지 API는 항상 X-Runmoa-Site-KeyAuthorization을 함께 보냅니다.

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/{orderId}내 주문 상세
GET/me/orders/{orderId}/details내 주문 상세 항목
GET/me/order-lines상품/콘텐츠 주문 항목 통합 목록
GET/me/order-lines/{lineItemId}상품/콘텐츠 주문 항목 통합 상세

마이페이지 첫 화면은 GET /me/orders로 주문 단위 목록을 보여줍니다.

const orders = await storefrontMe('/me/orders?page=1&limit=20');

대표 주문 목록 응답:

{
"orders": [
{
"ID": 12837,
"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/${orderId}`),
storefrontMe(`/me/orders/${orderId}/details`),
]);

대표 주문 상세 응답:

{
"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"
}
}

상품과 콘텐츠를 같은 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,
"item_id": 12001,
"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,
"item_id": 6401,
"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_idproduct:77, content:99031 같은 문자열 그대로 다음 API path에 넣습니다. open 버튼은 POST /me/content-entitlements/{lineItemId}/open을 호출하고, 구매확정/취소/환불/반품/교환 버튼은 POST /me/order-lines/{lineItemId}/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/{contentId}구매한 콘텐츠 상세/수강 데이터
GET/me/content-items/{contentId}/progress구매한 콘텐츠 진행률/시청 만료 정보

보유 콘텐츠 화면은 콘텐츠 유형과 상태를 path 값으로 조회합니다. 처음 붙일 때는 all/all로 조회하고, 사이트에서 사용하는 유형과 상태가 정해지면 탭을 나누면 됩니다.

const ownedContents = await storefrontMe('/me/contents/all/all?page=1&limit=20');

대표 보유 콘텐츠 응답:

{
"contents": [
{
"id": 501,
"content_id": 4321,
"title": "러닝 자세 클래스",
"name": "러닝 자세 클래스",
"type": "vod",
"status": "active",
"progress_rate": 42,
"expire_date": "2026-12-31"
}
],
"total": 1
}

콘텐츠 판매 스토어를 만들 때는 목록만으로 끝나지 않습니다. 사용자가 구매한 뒤 실제로 들어가는 화면은 GET /me/content-items/{contentId}를 기준으로 구현합니다.

const lesson = await storefrontMe('/me/content-items/6401');

이 응답은 공개 상세와 달리 “내 구매 이력 기준” 데이터를 내려줍니다. 즉 아래 같은 필드가 붙을 수 있습니다.

  • class: 내가 접근 가능한 실제 커리큘럼/회차 목록
  • chapters: VOD 챕터/영상 목록
  • watch_able: 지금 재생 가능한지
  • hold_blocked: 일시정지 상태 때문에 재생이 막혀 있는지
  • parent_class: 원본 콘텐츠 기본 정보

대표 응답 예시:

{
"class": [
{
"ID": 91201,
"title": "1강. 브랜드 핵심 메시지 정리",
"option_id": 55102,
"class_id": 6401,
"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
}
],
"chapters": [
{
"ID": 71,
"title": "브랜드 전략",
"curriculums_details": [
{
"ID": 91201,
"title": "1강. 브랜드 핵심 메시지 정리",
"paid": "1"
}
]
}
],
"watch_able": true,
"hold_blocked": false,
"parent_class": {
"ID": 6401,
"title": "브랜드 포지셔닝 워크숍"
}
}

공개 상세 GET /contents/{contentId}와 구매 후 상세 GET /me/content-items/{contentId}는 같은 화면 모델로 쓰지 않습니다. 공개 상세는 “판매용”, 구매 후 상세는 “학습/다운로드용”입니다.

구매한 콘텐츠를 실제로 보여줄 때는 line_item_id를 사용합니다.

메서드 경로 설명
POST/me/content-entitlements/{lineItemId}/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/{lineItemId}/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/{lineItemId}/review주문 항목 후기 작성 또는 수정

후기는 line_item.actionsreview_create 또는 review_updateenabled: 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/{lineItemId}/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());