베타 운영 안내·현재 베타로 운영하며, 유료 결제와 관련 기능은 준비가 끝나는 대로 순차적으로 연동합니다.운영 안내

새몰 호스티드 결제

앱이나 웹서비스에서 크레딧·기능 해제·구독을 팔 때 새몰이 호스팅하는 결제 화면을 연결하는 방법입니다. 결제창·정산·환불은 기존 새몰 주문과 같은 절차를 따릅니다.

처음 연결한다면 쉬운 결제 연결 가이드부터 따라가세요. 아래 문서는 서버에서 결제 링크를 만들고, 완료 웹훅으로 구매 결과를 반영하는 방법을 설명합니다.
앱 마켓에 배포한다면 결제 정책을 먼저 확인하세요

App Store와 Google Play에서 디지털 상품을 판매할 때는 각 마켓의 결제 규칙이 적용됩니다. 새몰 웹 결제 링크나 활성화 코드가 마켓의 인앱결제를 자동으로 대신하지 않습니다. 배포 국가·상품에 맞는 규칙은 Apple 심사 지침과 Google Play 결제 정책에서 확인하세요.

한눈에 보기

앱에서 createCheckout()을 부르면 결제 링크가 나옵니다. 사용자를 그 링크로 보내고, 결제가 끝나면 웹훅으로 알려 드립니다. 판매자 서비스는 구매 결과를 회원의 기능·이용권에 적용하고, 중복 알림과 환불·회수도 함께 처리해야 합니다.

// ① 결제 링크 받기
const { url } = await saemol.createCheckout({
  itemCode: 'credit_100',
  externalUserId: user.id,
  returnUrl: 'https://myapp.com/billing/done',
})
// ② 사용자를 보낸다
res.redirect(url)

// ③ 결제가 끝나면 checkout.completed 웹훅이 온다 → 앱에서 기능을 열어 준다
호스티드 결제는 새몰 마켓 주문과 같은 결제·정산·환불 경로를 사용합니다. 판매자는 별도 결제창을 만들지 않고 세션 API와 완료 웹훅만 연동합니다.

시작하기 전에

필요한 것어디서
게시 중인 상품판매자 대시보드 → 내 상품
연동 시크릿 (X-Saemol-Secret)상품 수정 화면의 «연동 정보» 카드
호스티드 결제 아이템(SKU) 1개 이상상품 수정 화면의 «호스티드 결제 아이템» 카드
웹훅 주소상품 수정 화면의 «연동 정보» 카드에 등록

SDK를 쓰면 가장 간단합니다. npm install @saemol/sdk 후 시크릿으로 클라이언트를 만들어 두세요. HTTP로 직접 호출해도 됩니다(아래에 예시가 있습니다).

import { Saemol } from '@saemol/sdk'

const saemol = new Saemol({ secret: process.env.SAEMOL_SECRET })

1단계 — 파는 것을 등록합니다

상품 수정 화면의 호스티드 결제 아이템 카드에서 앱이나 서비스 안에서 팔 것을 등록합니다. 여기 등록한 코드가 앱이 결제를 요청할 때 보내는 값입니다.

종류언제 쓰나반복 구매예
소모성(크레딧)쓰면 없어지는 것가능크레딧 100개 · 변환 10회권
영구 해제한 번 사면 계속 쓰는 기능불가(이미 보유하면 막힘)광고 제거 · 프로 기능
앱 내 구독주기마다 자동 결제주기마다 자동월 5,000원 프리미엄
금액은 앱이 보내지 않습니다. 앱은 코드만 보내고 가격은 새몰이 등록된 값을 씁니다. 클라이언트가 보낸 금액을 그대로 신뢰하면 결제 금액이 변조될 수 있기 때문입니다. 가격을 바꾸려면 이 화면에서 바꾸세요 — 앱은 고치지 않아도 됩니다.

가격은 부가가치세가 포함된 총액으로 입력합니다. 입력한 금액이 그대로 구매자 결제 금액입니다.

2단계 — 결제 링크를 받아 사용자를 보냅니다

결제가 필요한 순간(«충전하기» 버튼 등)에 서버에서 세션을 만드세요. 시크릿이 들어가므로 앱 클라이언트에서 직접 부르면 안 됩니다.

SDK
const checkout = await saemol.createCheckout({
  itemCode: 'credit_100',            // 등록한 아이템 코드
  externalUserId: user.id,           // 내 앱에서의 사용자 id — 웹훅으로 그대로 돌아온다
  returnUrl: 'https://myapp.com/billing/done',
  metadata: { source: 'editor_toolbar' },  // 그대로 돌아오는 임의 데이터
})

checkout.session_id   // 'c1f2…' — 나중에 조회할 때 쓴다
checkout.url          // 사용자를 보낼 주소
checkout.expires_at   // 30분 뒤 만료
checkout.item.price   // 5000 (부가세 포함)
HTTP로 직접
curl -X POST https://saemol.com/api/v1/checkout/sessions \
  -H "X-Saemol-Secret: $SAEMOL_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"item_code":"credit_100","external_user_id":"u_123","return_url":"https://myapp.com/billing/done"}'

받은 url로 사용자를 보냅니다. 리다이렉트해도 되고 새 창으로 열어도 됩니다. 그 화면은 새몰 도메인이고, 구매자는 거기서 로그인하고 결제합니다.

결제가 끝나면 returnUrl로 돌아옵니다 — ?checkout=completed&session_id=… 또는 ?checkout=cancelled&session_id=…가 붙습니다. 돌아온 것만으로 결제됐다고 판단하지 마세요. 사용자는 주소창을 직접 고칠 수 있습니다. 기능을 여는 판단은 다음 단계의 웹훅(또는 세션 조회)으로 하세요.

3단계 — 웹훅을 받아 기능을 열어 줍니다

결제가 완료되면 상품에 등록한 웹훅 주소로 checkout.completed가 갑니다. 여기서 크레딧을 올리거나 기능을 켜면 됩니다.

웹훅 수신 (Express)
import express from 'express'
import { verifyWebhook, SaemolWebhookError } from '@saemol/sdk'

app.post('/webhooks/saemol', express.raw({ type: 'application/json' }), async (req, res) => {
  let event
  try {
    event = verifyWebhook({
      rawBody: req.body,                    // JSON 파싱 전 원본 Buffer
      headers: req.headers,
      secret: process.env.SAEMOL_WEBHOOK_SECRET!,
    })
  } catch (err) {
    if (err instanceof SaemolWebhookError) return res.status(400).end()
    throw err
  }

  if (event.event === 'checkout.completed') {
    const d = event.data
    // d.external_user_id — 세션을 만들 때 넘긴 내 앱의 사용자 id
    // d.item_code        — 'credit_100'
    // d.quantity         — 소모성이면 지급 수량 (100)
    // d.order_id         — 새몰 주문 id (환불 시 같은 값으로 온다)
    // 내 DB에서 주문별 처리 기록과 크레딧 지급을 한 트랜잭션으로 구현
    await fulfillOrderOnce(d)
  }
  res.status(200).end()
})

페이로드에 실리는 값입니다.

필드설명
external_user_id세션을 만들 때 넘긴 앱 사용자 식별자 (안 넘겼으면 null)
item_code등록한 아이템 코드
item_kindconsumable · one_time · subscription
quantity소모성 아이템의 지급 수량 (그 외 null)
amount실제 결제 금액 (원, 부가세 포함)
order_id새몰 주문 id — 환불 웹훅이 같은 값으로 온다
session_id세션 id — 앱이 만든 요청과 대조할 때
metadata세션 생성 때 넘긴 임의 데이터
웹훅은 같은 결제로 두 번 올 수 있습니다(재전송·경합). 완료 이벤트는 order_id를 기준으로 중복 지급을 막으세요. 예제의 fulfillOrderOnce는 판매자가 구현해야 합니다. 처리 기록의 고유 제약과 크레딧 지급을 같은 DB 트랜잭션으로 묶어야 동시 재전송에도 중복 지급되지 않습니다.

웹훅을 놓쳤거나 돌아온 사용자를 그 자리에서 확인해야 하면 세션을 직접 조회하세요.

const s = await saemol.getCheckout({ sessionId })
if (s.status === 'completed') {
  // 결제 완료 — 웹훅 처리와 같은 함수를 부르되, order_id로 중복을 거른다
}

크레딧 잔액은 앱이 관리합니다

새몰은 결제·구매 결과와 지급 수량(quantity)을 알려 드립니다. 실제 크레딧 지급·잔액·차감은 판매자 서비스의 DB에서 처리해야 합니다.

환불이 일어나면 order.refunded와 entitlement.revoked가 갑니다. 같은 order_id로 오므로, 이미 처리한 회수인지 확인하고 환불 범위에 맞춰 되돌리세요. 이 처리를 안 해 두면 환불받고도 크레딧이 남습니다.

앱 내 구독

유료 운영 개방 후 구매자가 구독 조건과 결제 수단에 동의하면 해당 주기로 갱신됩니다. 첫 결제 때 checkout.completed와 subscription.created가 함께 가고, 이후 주기마다 subscription.renewed가 갑니다. 해지·만료는 subscription.cancelled·subscription.expired로 알려 드립니다.

구매자는 마이페이지 → 구독에서 직접 해지할 수 있습니다. 앱에 해지 화면을 따로 만들지 않아도 됩니다.

출시 전에 테스트하기

연동 정보 카드의 테스트 시크릿(test_로 시작)으로 세션을 만들면 테스트용 세션으로 구분됩니다. 테스트 시크릿이 유료 결제 제한을 해제하지는 않습니다. 현재 운영에서는 0원 아이템으로 주문·웹훅 연결을 확인하세요. 자세한 워크플로는 개발 단계부터 연동을 보세요.

가격을 0원으로 둔 아이템은 카드 결제 없이 신청할 수 있습니다. 새몰 로그인과 구매 완료 절차, 판매자 서비스의 지급 처리는 필요합니다.

돈은 이렇게 흐릅니다

항목내용
결제현재는 유료 거래가 제한됩니다. 실제 결제 처리 주체·수단·카드 명세 표기는 PG 계약과 운영 설정을 확인한 뒤 안내합니다
수수료판매자 플랜의 요율이 그대로 적용됩니다 — 요금제
정산마켓 판매와 같은 정산 정책을 적용합니다. 실제 지급은 판매자의 사업자·정산 정보 확인과 지급대행 운영 연결 후 제공합니다
부가세입력한 금액이 부가세 포함 총액입니다
환불구매자는 주문 내역에서 요청하고, 판매자는 판매 주문에서 처리합니다

주의할 것

  • 세션은 서버에서 만드세요. 연동 시크릿이 앱 클라이언트에 들어가면 누구나 세션을 만들 수 있습니다.
  • 돌아온 주소를 믿지 마세요. 기능을 여는 판단은 웹훅이나 세션 조회로 합니다.
  • 웹훅 중복을 거르세요. 완료와 환불·회수는 서로 다른 처리입니다. 이벤트 종류·대상과 처리 기록을 구분하고 DB 고유 제약·트랜잭션으로 중복 지급과 중복 회수를 막으세요.
  • 모바일 앱 스토어에 올린 앱은 다릅니다. iOS·Android 스토어는 디지털 재화에 자체 결제 규정을 두고 있어, 스토어 앱 안에서 이 결제창을 여는 것은 스토어 정책 위반이 될 수 있습니다. 웹앱·데스크톱·서버형 서비스에서 쓰세요.
  • 아이템을 지우지 말고 판매 중지를 쓰세요. 이미 팔린 주문이 그 아이템을 참조합니다.

막혔을 때

증상확인할 것
401 연동 시크릿이 일치하지 않습니다X-Saemol-Secret 헤더 · 테스트 시크릿과 실 시크릿을 섞지 않았는지
404 등록되지 않은 item_code입니다상품 수정 화면의 호스티드 결제 아이템 코드와 정확히 같은지(대소문자 구분)
409 판매 중지된 아이템입니다아이템이 «중지» 상태이거나 상품이 게시 중이 아님
403 유료 거래 이용 제한유료 거래가 활성화되어야 유료 결제를 사용할 수 있습니다. 0원 아이템으로 연동을 먼저 완성하세요
결제는 됐는데 웹훅이 안 온다상품 수정 화면에 웹훅 주소를 등록했는지 · https인지 · 서명 검증에서 400을 돌려주고 있지 않은지
결제 요청이 만료되었습니다세션은 30분입니다. 링크를 미리 만들어 두지 말고 결제 직전에 만드세요