인앱 결제 연동
앱 안에서 크레딧·기능 해제·구독을 팔 때, PG 계약 없이 새몰 결제를 붙이는 방법입니다. 결제창·정산·환불·세금계산서는 새몰이 처리하고 판매자는 결과만 받습니다.
한눈에 보기
앱에서 createCheckout()을 부르면 결제 링크가 나옵니다. 사용자를 그 링크로 보내고, 결제가 끝나면 웹훅으로 알려 드립니다. 판매자가 할 일은 이 두 가지뿐입니다.
// ① 결제 링크 받기
const { url } = await saemol.createCheckout({
itemCode: 'credit_100',
externalUserId: user.id,
returnUrl: 'https://myapp.com/billing/done',
})
// ② 사용자를 보낸다
res.redirect(url)
// ③ 결제가 끝나면 checkout.completed 웹훅이 온다 → 앱에서 기능을 열어 준다시작하기 전에
| 필요한 것 | 어디서 |
|---|---|
| 게시 중인 상품 | 판매자 대시보드 → 내 상품 |
| 연동 시크릿 (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단계 — 결제 링크를 받아 사용자를 보냅니다
결제가 필요한 순간(«충전하기» 버튼 등)에 서버에서 세션을 만드세요. 시크릿이 들어가므로 앱 클라이언트에서 직접 부르면 안 됩니다.
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 (부가세 포함)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가 갑니다. 여기서 크레딧을 올리거나 기능을 켜면 됩니다.
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_kind | consumable · 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.refunded와 entitlement.revoked가 갑니다. 같은 order_id로 오므로, 그때 지급했던 만큼 되돌리세요. 이 처리를 안 해 두면 환불받고도 크레딧이 남습니다.
앱 내 구독
종류를 «앱 내 구독»으로 등록하면 주기마다 자동으로 결제됩니다. 첫 결제 때 checkout.completed와 subscription.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분입니다. 링크를 미리 만들어 두지 말고 결제 직전에 만드세요 |