앱 연동 API
일반 /v1 API는 서버 전용이며 CORS를 허용하지 않아요.
앱의 verify, activate, deactivate는 브라우저 요청을 허용하고 라이선스 키로 인증해요.
엔드포인트
라이선스 검증
상품과 라이선스 키의 상태를 검증하고 nonce와 라이선스 mode가 포함된 응답에 Ed25519 서명을 붙여요.
요청 본문
keystring필수검증할 라이선스 키예요.
PAYRI-ABCDE-FGHJK-MNPQR-STUVWnoncestring필수응답 위조와 재전송을 확인할 nonce예요. hex 또는 base64로 보내고, 디코딩한 값은 16바이트 이상이어야 해요. 32바이트 hex를 권장해요.
^(?:(?:[A-Fa-f0-9]{2}){16,}|[A-Za-z0-9+/_-]{22,}={0,2})$·예 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdefproductIdstring필수라이선스가 연결된 상품 식별자예요.
prod_123activationIdstring상태를 확인할 활성화 식별자예요.
act_123응답
성공하면 HTTP 200과 라이선스 앱 응답을 돌려줘요.
오류
| 코드 | HTTP | 의미 |
|---|---|---|
LICENSE_RATE_LIMITED | 429 | 라이선스 API 호출 한도를 넘었어요. Retry-After 이후 다시 시도해 주세요. |
LICENSE_LOOKUP_BLOCKED | 429 | 라이선스 조회 제한에 걸려 요청할 수 없어요. Retry-After 이후 다시 시도해 주세요. |
표에 없는 공통 오류(입력 검증, 서버 오류)는 에러 코드에서 확인해요.
라이선스 활성화
서버가 새 활성화 식별자를 발급하고 mode가 포함된 서명된 라이선스 상태를 돌려줘요.
활성화 식별자는 요청에서 지정하지 않아요.
요청 본문
keystring필수활성화할 라이선스 키예요.
PAYRI-ABCDE-FGHJK-MNPQR-STUVWnoncestring필수응답 위조와 재전송을 확인할 nonce예요. hex 또는 base64로 보내고, 디코딩한 값은 16바이트 이상이어야 해요. 32바이트 hex를 권장해요.
^(?:(?:[A-Fa-f0-9]{2}){16,}|[A-Za-z0-9+/_-]{22,}={0,2})$·예 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdefproductIdstring필수라이선스가 연결된 상품 식별자예요.
prod_123labelstring기기 이름이에요.
내 컴퓨터응답
성공하면 HTTP 200과 라이선스 앱 응답을 돌려줘요.
오류
| 코드 | HTTP | 의미 |
|---|---|---|
LICENSE_RATE_LIMITED | 429 | 라이선스 API 호출 한도를 넘었어요. Retry-After 이후 다시 시도해 주세요. |
LICENSE_LOOKUP_BLOCKED | 429 | 라이선스 조회 제한에 걸려 요청할 수 없어요. Retry-After 이후 다시 시도해 주세요. |
표에 없는 공통 오류(입력 검증, 서버 오류)는 에러 코드에서 확인해요.
라이선스 비활성화
라이선스 키와 활성화 식별자로 한 기기의 활성화를 해제하고 mode가 포함된 서명된 상태를 돌려줘요.
요청 본문
activationIdstring필수해제할 활성화 식별자예요.
act_123keystring필수비활성화할 라이선스 키예요.
PAYRI-ABCDE-FGHJK-MNPQR-STUVWnoncestring필수응답 위조와 재전송을 확인할 nonce예요. hex 또는 base64로 보내고, 디코딩한 값은 16바이트 이상이어야 해요. 32바이트 hex를 권장해요.
^(?:(?:[A-Fa-f0-9]{2}){16,}|[A-Za-z0-9+/_-]{22,}={0,2})$·예 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdefproductIdstring필수라이선스가 연결된 상품 식별자예요.
prod_123응답
성공하면 HTTP 200과 라이선스 앱 응답을 돌려줘요.
오류
| 코드 | HTTP | 의미 |
|---|---|---|
LICENSE_RATE_LIMITED | 429 | 라이선스 API 호출 한도를 넘었어요. Retry-After 이후 다시 시도해 주세요. |
LICENSE_LOOKUP_BLOCKED | 429 | 라이선스 조회 제한에 걸려 요청할 수 없어요. Retry-After 이후 다시 시도해 주세요. |
표에 없는 공통 오류(입력 검증, 서버 오류)는 에러 코드에서 확인해요.
서명 공개키 조회
키 식별자별 Ed25519 공개키를 조회해 라이선스 응답 서명을 검증해요.
응답
성공하면 HTTP 200과 아래 필드를 돌려줘요.
keysarray of object라이선스 응답 서명 검증에 사용할 공개키 목록이에요.
하위 필드 3개
createdAtstring공개키가 유효해진 시각이에요.
kidstring공개키 식별자예요.
publicKeystringEd25519 원시 32바이트 공개키의 Base64 값이에요.
오류
표에 없는 공통 오류(서버 오류)는 에러 코드에서 확인해요.
라이선스 앱 응답
필드
activationobjectnull 가능이번 요청과 연결된 활성화 정보예요.
하위 필드 5개
activatedAtstring활성화가 시작된 시각이에요.
deactivatedAtstringnull 가능비활성화 시각이에요.
idstring서버가 발급한 활성화 식별자예요.
labelstringnull 가능활성화할 기기의 이름이에요.
lastVerifiedAtstringnull 가능마지막 검증 시각이에요.
activationCountnumber현재 활성화 수예요.
expiresAtstringnull 가능라이선스 만료 시각이에요.
issuedAtstring응답을 만든(서명한) 시각이에요. 앱은 이 값이 현재 시각에서 5분 이내인지 확인해 오래된 응답 재사용을 막아요.
maxActivationsnumbernull 가능허용된 최대 활성화 수예요. 무제한이면 null이에요.
modestringtest 또는 live 라이선스 환경을 알려줘요.
live, testnoncestring요청 때 보낸 16바이트 이상 nonce를 그대로 돌려줘요. hex 또는 base64로 보내며 32바이트 hex를 권장해요.
statusstring라이선스 검증 결과예요.
active, expired, revoked, pending, not_found요청 규칙
| 필드 | verify | activate | deactivate | 규칙 |
|---|---|---|---|---|
key | 필수 | 필수 | 필수 | 라이선스 키를 보내요. |
productId | 필수 | 필수 | 필수 | 상품 ID를 보내요. |
nonce | 필수 | 필수 | 필수 | 요청별 응답을 연결하는 nonce예요. |
activationId | 선택 | — | 필수 | 서버가 발급한 ID예요. |
label | — | 선택 | — | 활성화할 기기의 라벨이에요. |
nonce 규칙은 다음과 같아요.
- 요청마다 예측하기 어려운 새 base64 또는 hex 값을 보내고, 디코딩한 값은 16바이트 이상이어야 해요.
- hex는 짝수 길이 32자 이상이어야 해요.
- SDK처럼 32바이트 값을 hex 64자로 인코딩하는 형식을 권장해요.
- 응답의
nonce가 요청에 보낸 값과 같은지 확인해요.
응답 규칙
정상 응답은 HTTP 200이며 형식 오류·호출 제한·서버 오류는 각각 HTTP 400·429·500으로 응답해요.
maxActivations: null은 활성화 수 제한이 없고, expiresAt: null은 만료가 없다는 뜻이에요.
verify요청에activationId를 지정하면 해당 활성화의lastVerifiedAt을 갱신한 뒤 그 활성화를 반환해요.activate성공 응답에는 서버가 생성한 활성화 ID가 포함돼요. 이 ID를 저장해deactivate에 보내요.deactivate에 없는activationId를 보내면not_found, 이미 해제된 ID면 처음 해제한 결과가 그대로 와요.- 활성화 한도에 찼거나 라이선스가
active가 아니면 오류 대신activation: null로 와요. - 기기 사용을 허용하려면 서명·nonce를 확인하고
status가active인지 봐요. 운영 앱에서는mode가live인지도 확인하고,activation이 있으며activation.deactivatedAt이null인지 확인해요.
이 API는 mode로 분리되지 않아 PAYRI-TEST-로 시작하는 test 키도 같은 엔드포인트에서 응답해요.
| 상태 | 의미 |
|---|---|
active | 키가 사용 가능한 상태예요. |
expired | 키의 만료 시각이 지났어요. |
revoked | 셀러 폐기, 전액 환불, 구독 즉시 해지로 키가 폐기됐어요. |
pending | 내 서버의 발급 요청을 처리 중이에요. |
not_found | 일치하는 라이선스가 없어요. |
not_found와 호출 제한
키가 없거나 다른 상품·스토어에 속하거나 공용 키이면 모두 HTTP 200과 같은 not_found를 반환하며, 404나 구분 상세 정보는 반환하지 않아요.
| 기준 | 한도 | 넘으면 |
|---|---|---|
| IP | 분당 60회 | 429 LICENSE_RATE_LIMITED |
| 라이선스 키 | 키당 분당 30회 | 429 LICENSE_RATE_LIMITED |
not_found 차단 | 같은 IP에서 10분 안에 10회 | IP를 15분 동안 차단하고 429 LICENSE_LOOKUP_BLOCKED |
세 경우 모두 Retry-After 헤더에서 대기 시간을 초 단위로 확인해요.
HTTP 400 VALIDATION_FAILED와 두 HTTP 429 오류(LICENSE_RATE_LIMITED, LICENSE_LOOKUP_BLOCKED) 응답에는 Payri-Signature가 없어요.
응답 서명 검증
verify, activate, deactivate의 HTTP 200 응답은 원문 본문에 Ed25519 서명한 Payri-Signature: kid=<kid>, ed25519=<base64>를 포함하므로 파싱 전에 원문 바이트를 보관해요.
signing-keys 응답은 서명하지 않아요.
Payri-Signature에서kid와 base64 서명을 파싱해요.- 앱에 고정한 해당 공개 키로 응답 원문을 Ed25519 검증하고, 본문의
nonce가 요청값과 같은지,issuedAt이 현재 시각에서 5분 이내인지 확인해요.
서명, nonce, 시각 중 하나라도 검증에 실패하면 응답을 신뢰하지 마세요.
- 공개 키는 SPKI DER이 아닌 Ed25519 raw 32바이트를 base64로 인코딩하며, SDK 내장 테이블이나 개발자 문서의 값을 앱에 고정해 검증해요.
- 고정 키에 없는
kid는 검증 실패로 처리하고, 검증 대상 서버에서 받은 공개 키는 서명 검증에 사용하지 마세요. - 키 교체 확인용
GET /v1/licenses/signing-keys는 API 키 없이 모든 origin의 브라우저 CORS 요청을 허용하고, 캐시 가능한{ keys: [{ kid, publicKey, createdAt }] }를 반환해요.
키 교체
- 다음
kid의 공개 키를 SDK 내장 테이블과 개발자 문서에 현재 키와 함께 먼저 게시해요. - SDK 배포와 앱 업데이트가 퍼질 때까지 수개월 기다려요.
- 앱 업데이트 보급 상황을 기준으로 시점을 판단해 서버 서명을 새
kid로 전환해요.
서버 서명을 새 kid로 바꾸면 새 공개 키가 없는 앱은 즉시 응답 검증에 실패해요.
앱에 게시된 현재·다음 공개 키를 모두 고정하고, SDK 내장 테이블을 쓰면 서버 전환 전에 앱과 SDK를 업데이트해요.
Node.js 검증 예제
import { createPublicKey, verify } from 'node:crypto'
function verifyPayriResponse({ rawBody, signatureHeader, requestNonce, publicKeys }) {
const match = /^kid=([^,]+),\s*ed25519=([A-Za-z0-9+/=]+)$/.exec(signatureHeader ?? '')
if (!match) throw new Error('Payri-Signature 형식이 올바르지 않아요.')
const publicKeyBase64 = publicKeys.get(match[1])
if (!publicKeyBase64) throw new Error('공개 키를 찾을 수 없어요.')
const rawPublicKey = Buffer.from(publicKeyBase64, 'base64')
if (rawPublicKey.length !== 32) throw new Error('공개 키 길이가 올바르지 않아요.')
const spkiPrefix = Buffer.from('302a300506032b6570032100', 'hex')
const publicKey = createPublicKey({
key: Buffer.concat([spkiPrefix, rawPublicKey]),
format: 'der',
type: 'spki'
})
const signature = Buffer.from(match[2], 'base64')
if (!verify(null, rawBody, publicKey, signature)) {
throw new Error('응답 서명이 올바르지 않아요.')
}
const body = JSON.parse(rawBody.toString('utf8'))
if (body.nonce !== requestNonce) throw new Error('nonce가 일치하지 않아요.')
const issuedAt = Date.parse(body.issuedAt)
if (!Number.isFinite(issuedAt) || Math.abs(Date.now() - issuedAt) > 5 * 60 * 1000) {
throw new Error('응답 발급 시각이 허용 범위를 벗어났어요.')
}
return body
}Web Crypto 검증 예제
async function verifyPayriResponse({ response, requestNonce, publicKeys }) {
const header = response.headers.get('Payri-Signature') ?? ''
const match = /^kid=([^,]+),\s*ed25519=([A-Za-z0-9+/=]+)$/.exec(header)
if (!match) throw new Error('Payri-Signature 형식이 올바르지 않아요.')
const publicKeyBase64 = publicKeys.get(match[1])
if (!publicKeyBase64) throw new Error('공개 키를 찾을 수 없어요.')
const keyBytes = Uint8Array.from(atob(publicKeyBase64), char => char.charCodeAt(0))
const publicKey = await crypto.subtle.importKey(
'raw',
keyBytes,
{ name: 'Ed25519' },
false,
['verify']
)
const rawBody = new Uint8Array(await response.arrayBuffer())
const signature = Uint8Array.from(atob(match[2]), char => char.charCodeAt(0))
const valid = await crypto.subtle.verify(
{ name: 'Ed25519' },
publicKey,
signature,
rawBody
)
if (!valid) throw new Error('응답 서명이 올바르지 않아요.')
const body = JSON.parse(new TextDecoder().decode(rawBody))
if (body.nonce !== requestNonce) throw new Error('nonce가 일치하지 않아요.')
const issuedAt = Date.parse(body.issuedAt)
if (!Number.isFinite(issuedAt) || Math.abs(Date.now() - issuedAt) > 5 * 60 * 1000) {
throw new Error('응답 발급 시각이 허용 범위를 벗어났어요.')
}
return body
}