Skip to Content

앱 연동 API

일반 /v1 API는 서버 전용이며 CORS를 허용하지 않아요.
앱의 verify, activate, deactivate는 브라우저 요청을 허용하고 라이선스 키로 인증해요.

엔드포인트

라이선스 검증

상품과 라이선스 키의 상태를 검증하고 nonce와 라이선스 mode가 포함된 응답에 Ed25519 서명을 붙여요.

API 키 없이 호출해요

요청 본문

keystring필수

검증할 라이선스 키예요.

예 PAYRI-ABCDE-FGHJK-MNPQR-STUVW
noncestring필수

응답 위조와 재전송을 확인할 nonce예요. hex 또는 base64로 보내고, 디코딩한 값은 16바이트 이상이어야 해요. 32바이트 hex를 권장해요.

제약 최소 22자·패턴 ^(?:(?:[A-Fa-f0-9]{2}){16,}|[A-Za-z0-9+/_-]{22,}={0,2})$·예 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
productIdstring필수

라이선스가 연결된 상품 식별자예요.

예 prod_123
activationIdstring

상태를 확인할 활성화 식별자예요.

예 act_123

응답

성공하면 HTTP 200과 라이선스 앱 응답을 돌려줘요.

오류

코드HTTP의미
LICENSE_RATE_LIMITED429라이선스 API 호출 한도를 넘었어요. Retry-After 이후 다시 시도해 주세요.
LICENSE_LOOKUP_BLOCKED429라이선스 조회 제한에 걸려 요청할 수 없어요. Retry-After 이후 다시 시도해 주세요.

표에 없는 공통 오류(입력 검증, 서버 오류)는 에러 코드에서 확인해요.

라이선스 활성화

서버가 새 활성화 식별자를 발급하고 mode가 포함된 서명된 라이선스 상태를 돌려줘요.
활성화 식별자는 요청에서 지정하지 않아요.

API 키 없이 호출해요

요청 본문

keystring필수

활성화할 라이선스 키예요.

예 PAYRI-ABCDE-FGHJK-MNPQR-STUVW
noncestring필수

응답 위조와 재전송을 확인할 nonce예요. hex 또는 base64로 보내고, 디코딩한 값은 16바이트 이상이어야 해요. 32바이트 hex를 권장해요.

제약 최소 22자·패턴 ^(?:(?:[A-Fa-f0-9]{2}){16,}|[A-Za-z0-9+/_-]{22,}={0,2})$·예 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
productIdstring필수

라이선스가 연결된 상품 식별자예요.

예 prod_123
labelstring

기기 이름이에요.

예 내 컴퓨터

응답

성공하면 HTTP 200과 라이선스 앱 응답을 돌려줘요.

오류

코드HTTP의미
LICENSE_RATE_LIMITED429라이선스 API 호출 한도를 넘었어요. Retry-After 이후 다시 시도해 주세요.
LICENSE_LOOKUP_BLOCKED429라이선스 조회 제한에 걸려 요청할 수 없어요. Retry-After 이후 다시 시도해 주세요.

표에 없는 공통 오류(입력 검증, 서버 오류)는 에러 코드에서 확인해요.

라이선스 비활성화

라이선스 키와 활성화 식별자로 한 기기의 활성화를 해제하고 mode가 포함된 서명된 상태를 돌려줘요.

API 키 없이 호출해요

요청 본문

activationIdstring필수

해제할 활성화 식별자예요.

예 act_123
keystring필수

비활성화할 라이선스 키예요.

예 PAYRI-ABCDE-FGHJK-MNPQR-STUVW
noncestring필수

응답 위조와 재전송을 확인할 nonce예요. hex 또는 base64로 보내고, 디코딩한 값은 16바이트 이상이어야 해요. 32바이트 hex를 권장해요.

제약 최소 22자·패턴 ^(?:(?:[A-Fa-f0-9]{2}){16,}|[A-Za-z0-9+/_-]{22,}={0,2})$·예 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
productIdstring필수

라이선스가 연결된 상품 식별자예요.

예 prod_123

응답

성공하면 HTTP 200과 라이선스 앱 응답을 돌려줘요.

오류

코드HTTP의미
LICENSE_RATE_LIMITED429라이선스 API 호출 한도를 넘었어요. Retry-After 이후 다시 시도해 주세요.
LICENSE_LOOKUP_BLOCKED429라이선스 조회 제한에 걸려 요청할 수 없어요. Retry-After 이후 다시 시도해 주세요.

표에 없는 공통 오류(입력 검증, 서버 오류)는 에러 코드에서 확인해요.

서명 공개키 조회

키 식별자별 Ed25519 공개키를 조회해 라이선스 응답 서명을 검증해요.

API 키 없이 호출해요

응답

성공하면 HTTP 200과 아래 필드를 돌려줘요.

keysarray of object

라이선스 응답 서명 검증에 사용할 공개키 목록이에요.

하위 필드 3개
createdAtstring

공개키가 유효해진 시각이에요.

kidstring

공개키 식별자예요.

publicKeystring

Ed25519 원시 32바이트 공개키의 Base64 값이에요.

오류

표에 없는 공통 오류(서버 오류)는 에러 코드에서 확인해요.

라이선스 앱 응답

필드

activationobjectnull 가능

이번 요청과 연결된 활성화 정보예요.

하위 필드 5개
activatedAtstring

활성화가 시작된 시각이에요.

deactivatedAtstringnull 가능

비활성화 시각이에요.

idstring

서버가 발급한 활성화 식별자예요.

labelstringnull 가능

활성화할 기기의 이름이에요.

lastVerifiedAtstringnull 가능

마지막 검증 시각이에요.

activationCountnumber

현재 활성화 수예요.

expiresAtstringnull 가능

라이선스 만료 시각이에요.

issuedAtstring

응답을 만든(서명한) 시각이에요. 앱은 이 값이 현재 시각에서 5분 이내인지 확인해 오래된 응답 재사용을 막아요.

maxActivationsnumbernull 가능

허용된 최대 활성화 수예요. 무제한이면 null이에요.

modestring

test 또는 live 라이선스 환경을 알려줘요.

허용 값 live, test
noncestring

요청 때 보낸 16바이트 이상 nonce를 그대로 돌려줘요. hex 또는 base64로 보내며 32바이트 hex를 권장해요.

statusstring

라이선스 검증 결과예요.

허용 값 active, expired, revoked, pending, not_found

요청 규칙

필드verifyactivatedeactivate규칙
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 응답은 서명하지 않아요.

  1. Payri-Signature에서 kid와 base64 서명을 파싱해요.
  2. 앱에 고정한 해당 공개 키로 응답 원문을 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 }] }를 반환해요.

키 교체

  1. 다음 kid의 공개 키를 SDK 내장 테이블과 개발자 문서에 현재 키와 함께 먼저 게시해요.
  2. SDK 배포와 앱 업데이트가 퍼질 때까지 수개월 기다려요.
  3. 앱 업데이트 보급 상황을 기준으로 시점을 판단해 서버 서명을 새 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 }
Last updated on