베타 운영 중·유료 거래는 2026년 12월 17일 정식 오픈 후 시작됩니다. 지금은 상품을 미리 등록하고 둘러보실 수 있습니다.

인앱 결제 연동

앱 안에서 크레딧·기능 해제·구독을 팔 때, PG 계약 없이 새몰 결제를 붙이는 방법입니다. 결제창·정산·환불·세금계산서는 새몰이 처리하고 판매자는 결과만 받습니다.

한눈에 보기

앱에서 createCheckout()을 부르면 결제 링크가 나옵니다. 사용자를 그 링크로 보내고, 결제가 끝나면 웹훅으로 알려 드립니다. 판매자가 할 일은 이 두 가지뿐입니다.

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

// ③ 결제가 끝나면 checkout.completed 웹훅이 온다 → 앱에서 기능을 열어 준다
PG 계약이 필요 없습니다. 새몰이 이미 맺어 둔 결제 계약으로 승인됩니다. 카드사 심사(보통 7~10영업일)도, 사업자 서류 제출도, 결제창 개발도 하지 않습니다. 새몰에서 상품이 팔릴 때와 완전히 같은 경로를 쓰기 때문에 수수료·정산·환불·구매내역이 저절로 맞습니다.

시작하기 전에

필요한 것어디서
게시 중인 상품판매자 대시보드 → 내 상품
연동 시크릿 (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원 프리미엄
금액은 앱이 보내지 않습니다. 앱은 코드만 보내고 가격은 새몰이 등록된 값을 씁니다. 앱이 금액을 보낼 수 있으면 클라이언트를 뜯어 1원 결제를 만드는 길이 열리기 때문입니다. 가격을 바꾸려면 이 화면에서 바꾸세요 — 앱은 고치지 않아도 됩니다.

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

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 } from '@saemol/sdk'

app.post('/webhooks/saemol', express.raw({ type: '*/*' }), (req, res) => {
  const result = verifyWebhook({
    payload: req.body,                       // 원본 바이트 그대로 (JSON 파싱 전!)
    headers: req.headers,
    secret: process.env.SAEMOL_WEBHOOK_SECRET,
  })
  if (!result.valid) return res.status(400).end()

  if (result.event.event === 'checkout.completed') {
    const d = result.event.data
    // d.external_user_id — 세션을 만들 때 넘긴 내 앱의 사용자 id
    // d.item_code        — 'credit_100'
    // d.quantity         — 소모성이면 지급 수량 (100)
    // d.order_id         — 새몰 주문 id (환불 시 같은 값으로 온다)
    grantCredits(d.external_user_id, d.quantity)
  }
  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를 기록해 두고 이미 처리한 주문이면 건너뛰세요. 이 한 줄이 크레딧 이중 지급을 막습니다.

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

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

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

새몰은 지급까지 책임집니다 — 얼마를 줘야 하는지(quantity)를 웹훅으로 알려 드립니다. 잔액을 세고 차감하는 것은 앱의 몫입니다. 앱마다 소모 규칙이 다르기 때문입니다.

환불이 일어나면 order.refundedentitlement.revoked가 갑니다. 같은 order_id로 오므로, 그때 지급했던 만큼 되돌리세요. 이 처리를 안 해 두면 환불받고도 크레딧이 남습니다.

앱 내 구독

종류를 «앱 내 구독»으로 등록하면 주기마다 자동으로 결제됩니다. 첫 결제 때 checkout.completedsubscription.created가 함께 가고, 이후 주기마다 subscription.renewed가 갑니다. 해지·만료는 subscription.cancelled·subscription.expired로 알려 드립니다.

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

출시 전에 테스트하기

연동 정보 카드의 테스트 시크릿(test_로 시작)으로 세션을 만들면 테스트 결제로 처리됩니다. 실제 판매·정산과 섞이지 않고, 결제 화면에도 테스트 표시가 나옵니다. 자세한 워크플로는 개발 단계부터 연동을 보세요.

가격을 0원으로 둔 아이템은 결제창 없이 바로 지급됩니다. 웹훅 연결을 확인할 때 편합니다.

돈은 이렇게 흐릅니다

항목내용
결제새몰이 받습니다. 카드 명세에는 새몰로 표시되고, 결제 화면과 영수증에 상품·아이템 이름이 함께 적힙니다
수수료판매자 플랜의 요율이 그대로 적용됩니다 — 요금제
정산마켓 판매와 같은 주기로 함께 정산됩니다. 인앱이라고 따로 신청할 것이 없습니다
부가세입력한 금액이 부가세 포함 총액입니다
환불구매자는 주문 내역에서 요청하고, 판매자는 판매 주문에서 처리합니다

주의할 것

  • 세션은 서버에서 만드세요. 연동 시크릿이 앱 클라이언트에 들어가면 누구나 세션을 만들 수 있습니다.
  • 돌아온 주소를 믿지 마세요. 기능을 여는 판단은 웹훅이나 세션 조회로 합니다.
  • 웹훅 중복을 거르세요. order_id 하나면 됩니다.
  • 모바일 앱 스토어에 올린 앱은 다릅니다. iOS·Android 스토어는 디지털 재화에 자체 결제 규정을 두고 있어, 스토어 앱 안에서 이 결제창을 여는 것은 스토어 정책 위반이 될 수 있습니다. 웹앱·데스크톱·서버형 서비스에서 쓰세요.
  • 아이템을 지우지 말고 판매 중지를 쓰세요. 이미 팔린 주문이 그 아이템을 참조합니다.

막혔을 때

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