JavaScript SDK
서버 클라이언트
Payri 서버 클라이언트를 만들고 유예 키 알림을 설정해요.
import { Payri } from '@payri/sdk'
const payri = new Payri({
apiKey: process.env.PAYRI_API_KEY!,
onKeyDeprecated: (expiresAt) => {
console.warn('API 키 교체 만료 시각:', expiresAt)
},
})기본 baseUrl은 https://api.payri.kr이며, 별도 지정 시 호스트만 넣고 /v1을 붙이지 않으며 앱 라이선스 API를 제외한 POST는 Idempotency-Key를 자동 생성하고 GET과 멱등 키가 있는 요청만 재시도해요.
재시도는 같은 키를 쓰고 새 호출에서 재사용하려면 마지막 options 인자의 idempotencyKey를 지정해요; onKeyDeprecated를 지정하면 Payri-Key-Deprecated 헤더의 ISO 8601 만료 시각을 expiresAt으로 클라이언트당 한 번 전달하고 기본 경고는 출력하지 않아요.
결제 세션
checkout.sessions.create()로 세션을 만들고 구매자를 이동시켜요 (체크아웃 REST API).
const session = await payri.checkout.sessions.create({
productId: 'prod_123',
planId: 'plan_monthly',
interval: 'monthly',
successUrl: 'https://shop.example.com/thanks',
cancelUrl: 'https://shop.example.com/pricing',
})
return Response.redirect(session.url)주문 조회와 환불
주문을 조회하고 환불을 요청해요 (주문 REST API, 환불 REST API).
const order = await payri.orders.retrieve('ord_123')
if (order.status === 'paid') {
const refunded = await payri.orders.refund(order.id, {
amount: 5000,
reason: '고객 요청',
})
console.log(refunded.status)
}구독 조회와 해지
구독을 조회하고 해지해요 (구독 REST API).
const subscription = await payri.subscriptions.retrieve('sub_123')
const result = await payri.subscriptions.cancel(subscription.id, {
timing: 'period_end',
reason: '고객 요청',
})
console.log(result.subscription.status, result.timing, result.refundAmount)환불 요청 처리
환불 요청을 조회하고 승인·거절·보류해요 (환불 요청 REST API).
const requests = await payri.refundRequests.list({ page: 1, limit: 20 })
const requestId = requests.data[0]?.id
const decision: 'approve' | 'reject' | 'hold' = 'approve'
if (requestId) {
if (decision === 'approve') {
await payri.refundRequests.approve(requestId, { amount: 5000 })
} else if (decision === 'reject') {
await payri.refundRequests.reject(requestId, { reason: '환불 조건을 충족하지 않아요.' })
} else {
await payri.refundRequests.hold(requestId, {
type: 'in_review',
reason: '증빙을 확인하고 있어요.',
})
}
}구매자 포털 세션
포털 세션을 만들고 구매자를 이동시켜요 (포털 세션 REST API).
const portal = await payri.portalSessions.create({
subscriptionId: 'sub_123',
returnUrl: 'https://shop.example.com/account',
})
return Response.redirect(portal.url)서버에서 라이선스 조회
서버에서 라이선스를 조회해요 (라이선스 REST API).
const licenses = await payri.licenses.list({
productId: 'prod_123',
status: 'active',
page: 1,
limit: 20,
})
const firstLicense = licenses.data[0]
if (firstLicense) {
const license = await payri.licenses.retrieve(firstLicense.id)
console.log(license.buyerEmail, license.status, license.mode)
}웹훅 서명 검증과 이벤트 분기
JSON 파싱 전 원본 본문을 Webhooks.constructEvent()에 전달해 서명을 검증하고, event.type에 따라 data 타입을 좁혀요 (서명 문서, 이벤트 레퍼런스).
import express from 'express'
import { PayriWebhookError, Webhooks } from '@payri/sdk'
const app = express()
app.post('/webhooks/payri', express.raw({ type: 'application/json' }), async (req, res) => {
let event
try {
event = await Webhooks.constructEvent({
payload: req.body,
headers: req.headers,
secret: process.env.PAYRI_WEBHOOK_SECRET!,
})
} catch (error) {
if (error instanceof PayriWebhookError) return res.sendStatus(400)
throw error
}
if (event.type === 'order.paid') {
switch (event.data.subscriptionEvent) {
case 'renewal':
await recordRenewal(event.data)
break
case 'proration':
await recordProration(event.data)
break
case 'initial':
case null:
await fulfillOrder(event.data)
break
}
}
res.sendStatus(200)
})license.issue 처리
Webhooks.handle()은 서명과 license.issue 응답 형식을 검증해요 (라이선스 발급 웹훅).
import express from 'express'
import { Webhooks } from '@payri/sdk'
const app = express()
app.post('/webhooks/payri', express.raw({ type: 'application/json' }), async (req, res) => {
const result = await Webhooks.handle({
payload: req.body,
headers: req.headers,
secret: process.env.PAYRI_WEBHOOK_SECRET!,
onEvent: async (event) => processEvent(event),
onLicenseIssue: async (event) =>
licenseStore.getOrCreate(event.id, () => issueAndSaveKey(event.data)),
})
res.status(result.status).set(result.headers).send(result.body)
})라이선스 앱 클라이언트
PayriLicensing은 앱 라이선스를 확인·활성화하며 요청마다 32바이트 nonce를 hex 64자로 보내고, 응답 서명·nonce 일치·5분 이내 issuedAt을 검증해요.
| 메서드 | 인자 |
|---|---|
verify({ key, activationId? }) | activationId는 선택이에요. |
activate({ key, label? }) | label은 선택이에요. |
deactivate({ key, activationId }) | activationId를 전달해요. |
라이선스 앱 API의 429는 PayriRateLimitError로 전달되며 code와 retryAfterSeconds에서 Retry-After 초를 확인해요 (라이선스 앱 API, 응답 서명).
publicKeys 옵션은 내장 공개 키 목록을 확장하고 같은 kid가 있으면 옵션 값으로 덮어쓰며, 목록에 없는 kid는 검증 오류를 내고 signing-keys를 자동 조회하지 않아요.
키 교체
키 교체 절차는 라이선스 앱 API를 참고해요.
import { PayriLicensing } from '@payri/sdk'
const licensing = new PayriLicensing({
productId: 'prod_123',
publicKeys: {
'<현재 kid>': '<base64 현재 공개 키>',
'<다음 kid>': '<base64 다음 공개 키>',
},
})
const verified = await licensing.verify({ key: licenseKey })
const activated = await licensing.activate({
key: licenseKey,
label: '내 노트북',
})
const activationId = activated.activation?.id
if (activationId) {
await saveActivationId(activationId)
await licensing.deactivate({ key: licenseKey, activationId })
}오류 처리
오류 타입은 PayriApiError, PayriRateLimitError, PayriWebhookError, PayriLicenseSignatureError로 나뉘며, 서명 오류가 나면 검증 결과를 신뢰하지 않아요 (API 오류 코드).
import { PayriApiError, PayriAuthError, PayriRateLimitError } from '@payri/sdk'
try {
await payri.orders.retrieve('ord_123')
} catch (error) {
if (error instanceof PayriAuthError && error.code === 'API_KEY_SCOPE_MISSING') {
const details = error.details as { required: string[] }
console.error('필요한 권한:', details.required)
} else if (error instanceof PayriAuthError && error.code === 'API_KEY_INVALID') {
console.error('API 키를 확인해 주세요.')
} else if (error instanceof PayriAuthError && error.code === 'API_KEY_IP_NOT_ALLOWED') {
console.error('요청 IP 제한을 확인해 주세요.')
} else if (error instanceof PayriRateLimitError && error.code === 'RATE_LIMITED') {
console.error('재시도 대기 시간(초):', error.retryAfterSeconds)
} else if (error instanceof PayriApiError && error.code === 'VALIDATION_FAILED') {
const details = error.details as { fields: Array<{ field: string; message: string }> }
console.error('입력 오류:', details.fields)
} else if (error instanceof PayriApiError && error.code === 'IDEMPOTENCY_KEY_CONFLICT') {
console.error('같은 멱등 키에 다른 본문을 보냈어요.')
} else if (error instanceof PayriApiError && error.code === 'IDEMPOTENCY_IN_PROGRESS') {
console.error('같은 멱등 키 요청이 처리 중이에요.')
} else if (error instanceof PayriApiError) {
console.error(error.status, error.code, error.details)
} else {
throw error
}
}