OTP API
OTP API는 SMS·카카오 알림톡·이메일 채널로 일회용 인증을 처리하는 API입니다. 숫자 코드를 입력받는 code 모드와, 수신자가 링크를 눌러 인증을 완료하는 **link 모드(매직링크)**를 지원합니다.
발송 → 검증 → (필요 시) 재발송 → 상태 조회의 전 과정을 이 문서에서 다룹니다.
인증과 스코프
모든 OTP 엔드포인트는 Notification API 공통 인증(API 키 + HMAC 서명)을 사용합니다. 요청마다 아래 4개 헤더가 필요합니다. 서명 생성 방법은 인증 문서를 참고하세요.
| 헤더 | 설명 |
|---|---|
x-api-key | keyId.keySecret 형식의 API 키 |
x-timestamp | Unix epoch 초. 허용 오차를 벗어나면 401 |
x-nonce | 요청마다 고유한 임의 문자열. 재사용 시 401 (Replay detected) |
x-signature | HMAC-SHA256(hmacSecret, "METHOD\npath\ntimestamp\nnonce\nsha256(rawBody)") 의 hex |
엔드포인트별로 요구하는 스코프가 다릅니다. 스코프가 부족하면 403 (API key missing required scope: <scope>)으로 응답합니다.
| 스코프 | 허용 작업 |
|---|---|
otp:send | 발송, 재발송 |
otp:verify | 코드 검증, 상태 조회 |
엔드포인트 요약
| 메서드 | 경로 | 스코프 | 설명 |
|---|---|---|---|
| POST | /v1/otp | otp:send | OTP 발송 요청 |
| POST | /v1/otp/:verificationId/verify | otp:verify | 코드 검증 |
| POST | /v1/otp/:verificationId/resend | otp:send | 재발송 (코드 회전) |
| GET | /v1/otp/:verificationId | otp:verify | 상태 조회 (폴링) |
| GET | /v1/otp/link/:linkToken | 없음 (공개) | 매직링크 랜딩 페이지 |
| POST | /v1/otp/link/:linkToken | 없음 (공개) | 매직링크 인증 완료 |
OTP 발송
otp:send지정한 채널로 인증 코드(또는 매직링크)를 발송하고 검증 세션을 생성합니다.
요청 필드
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
channel | string | O | sms, kakao_alimtalk, email 중 하나 |
to | string | O | 전화번호 또는 이메일. 최대 320자 |
mode | string | X | code(기본) 또는 link |
purpose | string | X | 용도 태그(signup, login, reset 등). 최대 60자 |
brandName | string | X | 메시지 양식의 브랜드 슬롯. 최대 20자 |
recipientName | string | X | 수신자 이름. 최대 120자 |
templateCode | string | X | 알림톡 템플릿 코드. kakao_alimtalk 채널에서는 필수 |
templateVariables | object | X | 알림톡 템플릿 추가 변수 (문자열 맵) |
preferredProviderAccountIds | string[] | X | 우선 사용할 공급자 계정 UUID. 최대 10개 |
전화번호는 공백·괄호·.·- 를 제거해 정규화하고, 이메일은 trim 후 소문자로 정규화합니다.
알림톡 채널에서 templateCode 를 생략하면 400 (templateCode is required for kakao_alimtalk channel)이 발생합니다.
templateVariables 중 code, brand, minutes, (link 모드의) link 는 서버가 값을 주입하므로 직접 지정해도 덮어써집니다.
요청 예시
curl -X POST https://api.posmit.io/v1/otp \
-H "Content-Type: application/json" \
-H "x-api-key: pk_xxx.sk_xxx" \
-H "x-timestamp: 1783944000" \
-H "x-nonce: 4f9d2c1e-unique-nonce" \
-H "x-signature: COMPUTED_HMAC_SIGNATURE" \
-d '{
"channel": "sms",
"to": "010-1234-5678",
"mode": "code",
"purpose": "login",
"brandName": "Posmit"
}'응답
모든 2xx 응답은 공통 규약에 따라 data + meta.timestamp 봉투(envelope)로 감싸 반환됩니다.
{
"data": {
"verificationId": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"mode": "code",
"channel": "sms",
"expiresAt": "2026-07-10T12:03:00.000Z",
"resendAvailableAt": "2026-07-10T12:00:30.000Z"
},
"meta": { "timestamp": "2026-07-10T12:00:00.000Z" }
}| 필드 | 설명 |
|---|---|
verificationId | 검증 세션 ID. 이후 검증·재발송·조회에 사용합니다 |
status | 항상 pending 으로 시작합니다 |
expiresAt | 코드/링크 만료 시각 (기본 발송 후 3분) |
resendAvailableAt | 재발송이 가능해지는 시각 (쿨다운 기본 30초) |
코드·링크 토큰의 평문은 저장되지 않습니다. 코드는 세션에 바인딩된 HMAC 해시로만, 링크 토큰은 SHA-256 해시로만 보관되므로 응답이나 조회로 평문을 다시 얻을 수 없습니다.
발송이 동기적으로 실패하면(예: 미승인 알림톡 템플릿) 세션이 failed 처리되고 요청 자체가 에러로 응답합니다.
이 경우 새 발송 요청을 다시 보내야 합니다.
코드 검증
otp:verify수신자가 입력한 코드를 검증합니다. 성공·실패 모두 HTTP 200 으로 응답하고, 결과는 status 필드로 구분합니다.
요청 예시
curl -X POST https://api.posmit.io/v1/otp/550e8400-e29b-41d4-a716-446655440000/verify \
-H "Content-Type: application/json" \
-H "x-api-key: pk_xxx.sk_xxx" \
-H "x-timestamp: 1783944060" \
-H "x-nonce: another-unique-nonce" \
-H "x-signature: COMPUTED_HMAC_SIGNATURE" \
-d '{ "code": "123456" }'응답 — 성공
{
"data": {
"status": "verified",
"verificationToken": "eyJ2aWQiOiI1NTBlODQwMC4uLiJ9.9a3b1c...",
"verifiedAt": "2026-07-10T12:01:20.000Z"
},
"meta": { "timestamp": "2026-07-10T12:01:20.000Z" }
}응답 — 실패
{
"data": {
"status": "failed",
"reason": "invalid_code",
"attemptsRemaining": 3
},
"meta": { "timestamp": "2026-07-10T12:01:25.000Z" }
}reason | 의미 | attemptsRemaining |
|---|---|---|
expired | 세션이 없거나 만료됨 | 0 |
not_code_mode | link 모드 세션에 코드 검증을 시도함 | 0 |
too_many_attempts | 최대 시도 횟수 도달. 세션이 failed 로 종료됨 | 0 |
invalid_code | 코드 불일치 | 남은 시도 횟수 |
코드 비교는 timing-safe 비교로 수행되고, 성공 시 코드 해시는 세션에서 즉시 제거됩니다.
이미 verified 인 세션에 다시 verify 를 호출하면 멱등으로 verified 와 새로 서명된 토큰을 반환합니다
(검증 마커는 토큰 TTL 동안 유지됩니다).
재발송
otp:send같은 세션에서 새 코드(또는 새 링크)를 발송합니다. 요청 바디는 없고, 응답은 발송 응답과 동일한 형태입니다
(새 expiresAt / resendAvailableAt).
재발송은 항상 새 코드로 회전합니다. 평문을 저장하지 않으므로 같은 코드를 다시 보낼 수 없고, 이전 코드·링크는 즉시 무효화됩니다. 시도 횟수는 0으로 초기화되고 TTL도 새 코드 기준으로 재설정됩니다.
에러
| 상태 코드 | 조건 | detail |
|---|---|---|
| 404 | 세션 없음 / 다른 워크스페이스 | OTP verification not found or expired |
| 400 | 이미 검증 완료 | OTP is already verified |
| 400 | 만료됨 | OTP has expired |
| 429 | 최대 재발송 초과 | Maximum resend count exceeded |
| 429 | 쿨다운 중 | Resend cooldown active |
에러는 공통 규약대로 application/problem+json 형식으로 응답합니다. 쿨다운 중 재발송 예시입니다.
{
"type": "https://api.posmit.io/errors/noti-42900",
"title": "Too Many Requests",
"status": 429,
"detail": "Resend cooldown active",
"code": "NOTI-42900",
"instance": "/v1/otp/550e8400-e29b-41d4-a716-446655440000/resend",
"timestamp": "2026-07-10T12:00:12.000Z"
}쿨다운이 풀리는 시각은 에러 응답에 포함되지 않으므로, 직전 발송/재발송 응답의 resendAvailableAt 을 기준으로 재시도 시점을 계산하세요.
재발송도 수신처별 발송 한도(아래 만료 정책과 레이트리밋)를 소모합니다.
상태 조회
otp:verify검증 세션의 현재 상태를 조회합니다. 매직링크의 크로스 디바이스 완료 감지에도 이 엔드포인트를 폴링합니다.
{
"data": {
"verificationId": "550e8400-e29b-41d4-a716-446655440000",
"status": "verified",
"channel": "sms",
"mode": "code",
"purpose": "login",
"recipientMasked": "*******5678",
"attempts": 1,
"resendCount": 0,
"expiresAt": "2026-07-10T12:03:00.000Z",
"verifiedAt": "2026-07-10T12:01:20.000Z",
"verificationToken": "eyJ2aWQiOi4uLg.9a3b1c..."
},
"meta": { "timestamp": "2026-07-10T12:01:30.000Z" }
}status는pending,verified,expired,failed,canceled중 하나입니다.verificationToken은status가verified일 때만 포함됩니다.pending세션이 만료 시각을 지났으면 조회 시점에expired로 전이됩니다(lazy 처리).- 수신처는 마스킹된 형태로만 반환됩니다. 이메일은 로컬파트 앞 2자만(
se****@naver.com), 전화번호는 끝 4자리만 노출합니다. - 레코드가 없거나 다른 워크스페이스의 세션이면 404 (
OTP verification not found)입니다.
매직링크 (link 모드)
mode: "link" 로 발송하면 수신자는 코드 입력 대신 메시지의 링크를 눌러 인증을 완료합니다.
링크 URL 형식은 https://api.posmit.io/v1/otp/link/{linkToken} 입니다.
HTML 랜딩 페이지만 렌더링하며 상태를 변경하지 않습니다. 메일 보안 스캐너나 링크 미리보기 봇이 링크를 자동 GET 해도 인증이 완료되지 않도록 하기 위한 설계입니다. 토큰이 유효하면 “인증 완료하기” 버튼(POST 폼)이, 무효·만료면 실패 페이지가 표시됩니다.
실제 인증 완료 요청입니다. 랜딩 페이지의 버튼이 이 요청을 보내고, 성공·실패 HTML을 반환합니다.
이미 verified 인 세션이면 멱등으로 성공 처리됩니다.
verificationToken 은 수신자 브라우저에 절대 노출되지 않습니다.
링크 완료는 세션 상태만 verified 로 전환하고, 토큰은 고객사 백엔드가
GET /v1/otp/:verificationId 로 받아야 합니다.
크로스 디바이스 완료 감지 (폴링 패턴)
수신자가 다른 기기(휴대폰)에서 링크를 누르는 동안, 원래 화면(웹 로그인 페이지 등)의 백엔드는 상태 조회 엔드포인트를 폴링해 완료를 감지합니다.
async function waitForLinkVerification(verificationId: string): Promise<string | null> {
const deadline = Date.now() + 180_000; // 세션 TTL(기본 3분)만큼 대기
while (Date.now() < deadline) {
const res = await fetch(`https://api.posmit.io/v1/otp/${verificationId}`, {
headers: buildHmacAuthHeaders('GET', `/v1/otp/${verificationId}`), // 인증 문서 참고
});
const body = await res.json();
if (body.data.status === 'verified') return body.data.verificationToken;
if (body.data.status === 'expired' || body.data.status === 'failed') return null;
await new Promise((r) => setTimeout(r, 2000)); // 2초 간격 폴링
}
return null;
}verificationToken
검증 성공 시 발급되는 토큰은 JWT가 아니라 자체 컴팩트 형식입니다.
base64url(JSON payload) + "." + HMAC-SHA256-hex(OTP_SIGNING_SECRET, base64url 부분)payload 클레임:
| 클레임 | 의미 |
|---|---|
vid | verificationId |
wsid | 워크스페이스 ID |
purpose | 발송 시 지정한 용도 태그 |
channel | 발송 채널 |
sub | 마스킹된 수신처 |
iat / exp | 발급·만료 시각. TTL 기본 300초 |
고객사에서 토큰을 신뢰하는 방법은 두 가지입니다.
- A 패턴 (오프라인 검증) — 서명 시크릿을 공유받았다면 토큰의 HMAC을 직접 재계산해 검증합니다.
- B 패턴 (서버 재확인, 권장) —
GET /v1/otp/:verificationId를 호출해 서버에서 결과를 재확인합니다.
만료 정책과 레이트리밋
v1은 전역 기본 정책이 적용됩니다 (워크스페이스별 커스터마이징은 아직 지원하지 않습니다).
| 항목 | 기본값 |
|---|---|
| 코드 길이 | 6자리 |
| 코드/링크 유효시간 (TTL) | 180초 (3분) |
| 최대 검증 시도 | 5회 |
| 재발송 쿨다운 | 30초 |
| 최대 재발송 횟수 | 3회 |
| 수신처별 시간당 발송 한도 | 5회 |
| 수신처별 일일 발송 한도 | 10회 |
| verificationToken TTL | 300초 (5분) |
수신처별 발송 한도를 초과하면 발송·재발송 요청이 429 (application/problem+json)로 거절됩니다.
{
"type": "https://api.posmit.io/errors/noti-42900",
"title": "Too Many Requests",
"status": 429,
"detail": "OTP send rate limit exceeded",
"code": "NOTI-42900",
"instance": "/v1/otp",
"timestamp": "2026-07-10T12:00:00.000Z"
}- 시간당 한도는 다음 UTC 정시에, 일일 한도는 UTC 자정에 초기화됩니다. 남은 대기 시간은 응답에 포함되지 않습니다.
발송 한도는 워크스페이스 + 정규화된 수신처 단위로 집계됩니다. 같은 번호에 하이픈 유무만 다르게 보내도 동일 수신처로 계산됩니다.