← 블로그 목록

휴대폰 인증 API를 만들었어요: 인증 요금은 따로 안 받아요

ClawOps Verify API 소개. 건당 인증료나 월 기본료 없이 쓰시던 요금제 그대로 쓸 수 있어요. 문자·음성·수신문자 세 가지 방식을 channel 값만 바꿔서 붙입니다.

휴대폰 인증 API를 만들었어요: 인증 요금은 따로 안 받아요

ClawOps Verify API로 문자·음성·수신문자 본인인증을 연동하는 과정을 안내하는 이미지

가입 화면에 휴대폰 인증을 붙일 때, 문자 발송 API만 있으면 될 것 같죠. 그런데 막상 짜보면 문자 보내는 코드는 세 줄이고 나머지가 전부예요.

  • 코드를 어디에 저장하지? 평문으로 두긴 그렇고, Redis에 두면 TTL 관리를 따로 해야 하고요.
  • 몇 번 틀리면 막지? 그 카운터는 또 어디에 두고요.
  • 재전송을 누르면 앞에 보낸 코드는 죽여야 하나, 살려둬야 하나?
  • 같은 번호로 요청이 쏟아지면 어떻게 막지?
  • 문자를 아예 못 받는 사용자한테는 뭘 해주지?

그래서 이 상태를 저희가 대신 관리하기로 했어요. Verify API를 열었고, 쓰는 쪽에서는 두 번만 부르면 됩니다.

그리고 인증 때문에 따로 내실 요금은 없어요. 건당 인증료도, 인증 전용 월 기본료도 받지 않아요. 문자 인증은 쓰시던 요금제의 문자 1건으로 나가고, 수신문자 인증은 아예 0원이에요.

두 번의 호출

먼저 인증을 하나 만들어요.

curl -X POST https://api.claw-ops.com/v2/verifications \
  -H "Authorization: Bearer $CLAWOPS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "01012345678", "channel": "sms" }'
{
  "verificationId": "VE1a2b3c4d5e6f7a8b",
  "status": "pending",
  "remainingAttempts": 5,
  "expiresAt": "2026-09-19T04:03:00.000Z",
  "instruction": null
}

사용자가 문자로 받은 코드를 입력하면, 그 값을 그대로 넘겨서 맞는지 물어보면 돼요.

curl -X POST https://api.claw-ops.com/v2/verifications/VE1a2b3c4d5e6f7a8b/check \
  -H "Authorization: Bearer $CLAWOPS_API_KEY" \
  -d '{ "code": "482913" }'
{ "status": "verified", "valid": true, "verifiedAt": "2026-09-19T04:01:12.000Z" }

세션에 들고 있어야 할 건 verificationId 하나예요. 코드는 어떤 응답에도 실리지 않아요. 사용자에게 가는 경로는 문자 본문 하나뿐이고, 저장도 해시로만 하거든요.

경로에 계정 ID가 없는 게 눈에 띄실 텐데, 오타가 아니에요. Verify는 v2에서 시작하는 첫 리소스라 계정은 API 키가 정하고, 요청 필드는 camelCase를 써요. 인증 헤더와 에러 모양({ error, code })은 v1과 같아서 쓰던 대로 쓰시면 됩니다.

이 두 줄이 대신 해주는 일

  • 코드 생성과 해시 저장 — 원문은 저희 서버에도 남지 않아요
  • TTL 만료 — 기본 180초, ttlSeconds로 60~600초 사이에서 조절해요
  • 대조 시도 5회 제한과 remainingAttempts 카운트
  • 재요청 쿨다운 60초, 같은 번호로 시간당 5건·하루 10건·동시 5건
  • 하나가 통과하면 같은 번호의 나머지 건을 닫는 정리

직접 만들면 Redis 키 설계부터 시작해야 하는 것들이에요.

코드가 틀렸을 땐 200이 와요

{ "status": "pending", "valid": false, "remainingAttempts": 4 }

처음 보면 좀 이상하죠. 틀렸으면 4xx여야 할 것 같은데요.

4xx는 더 시도해도 소용없는 경우에만 써요. 만료, 시도 소진, 이미 통과. 이 셋뿐이고, 셋 다 화면이 할 일이 같아요. 새 인증을 만들어야 하죠. 그런데 오타는 달라요. 남은 횟수를 보여주고 다시 받으면 되고, 그건 실패가 아니라 그 화면의 정상 동작이니까요.

그래서 호출하는 쪽 코드가 이렇게 갈려요.

// 다시 받으면 되는 것
if (res.ok) {
  const { valid, remainingAttempts } = await res.json();
  return valid ? done() : retry(remainingAttempts);
}

// 새로 시작해야 하는 것
if (res.status === 422) return restart((await res.json()).code);

오타를 예외로 만들면 제일 흔한 경로가 catch 안으로 들어가요. 그리고 거기서 다시 「진짜 에러」와 「그냥 틀린 것」을 가르게 되죠. Twilio Verify도 같은 선택을 했어요.

재전송 버튼은 같은 요청을 한 번 더

재전송 전용 경로는 따로 없어요. POST /v2/verifications를 다시 부르면 새 건이 하나 더 생기고, 앞에 만든 건도 그대로 살아 있어요. 둘 중 어느 코드로든 통과하고, 하나가 통과하면 나머지는 canceled가 됩니다.

진행 중인 건이 있다고 409로 막지는 않아요. 막으면 문자를 못 받은 사용자는 만료를 기다리는 것 말고 방법이 없어지거든요.

대신 60초 쿨다운이 429로 와요. Retry-After 헤더에 남은 초가 들어 있으니 그대로 카운트다운으로 그려주시면 됩니다.

HTTP/1.1 429 Too Many Requests
Retry-After: 43

문자가 안 가는 사용자에게는 전화로

channel만 바꾸면 돼요. 대조하는 코드는 한 줄도 안 바뀌어요. 같은 check를 그대로 부르시면 됩니다.

-d '{ "to": "01012345678", "channel": "call", "ttlSeconds": 300 }'

전화를 걸어서 코드를 한 자리씩 끊어 두 번 읽어줘요. 처음에는 한 문장으로 만들어 구두점으로 끊으려고 했는데, 음성 합성이 구두점을 무시하더라고요. 실제로 재보니 인증번호는 3. 5. …와 인증번호는, 3, 5, …의 합성 결과가 MD5까지 같았어요. 그렇게 붙여 읽은 353986은 8kHz 전화 음질에서 「3539816」으로 들렸고요. 받아 적은 사람이 자리를 하나 더 센 거죠.

그래서 이 채널을 쓰실 때 알아두시면 좋은 게 두 가지예요.

ttlSeconds를 넉넉히 주세요. 6자리 기준으로 낭독만 약 24초, 통화 전체로는 32초가 걸려요. 최소값인 60초로 걸어두면 상대가 20초 만에 받았을 때 다 듣고 입력할 시간이 10초 남짓밖에 안 남아요. 300초를 권해드려요.

미응답은 check로 알 수 없어요. 전화를 받지 않거나 통화가 실패하면 그 건은 failed로 닫히고 다시 걸지 않아요. 이건 웹훅으로만 알 수 있어요. 대신 미응답은 통화료가 0원이에요. 과금 원점이 응답 시각이거든요.

입력란 자체를 없애고 싶다면

방향을 뒤집는 방법도 있어요. 국내에서 MO 인증이라고 부르는 방식인데, 사용자가 저희에게 문자를 보내는 구조예요.

-d '{ "to": "01012345678", "channel": "sms_inbound", "from": "07052358010" }'
"instruction": {
  "type": "sms_inbound",
  "sendTo": "07052358010",
  "body": "482913",
  "deadline": "2026-09-19T04:03:00.000Z"
}

instruction을 그대로 화면에 띄우시면 돼요. 모바일이면 딥링크로 문자앱을 미리 채워줄 수 있어서, 사용자는 전송 버튼만 한 번 누르면 끝이에요.

<a href="sms:07052358010?body=482913">문자 앱으로 보내기</a>

이 채널에서는 check를 부르지 않아요. 부르면 422 check_not_supported가 와요. 대조할 코드를 사용자가 갖고 있지 않으니까요. 결과는 웹훅으로 옵니다.

문자 본문이 코드와 완전히 같을 필요는 없어요. 사용자는 보통 안내 문구를 같이 보내거든요. 실제로 폰에서 문자를 보내보면서 확인한 로그예요.

13:57:05  111222                  →  불일치, 시도 1회 차감
13:57:22  이거 뭐예요?              →  무시, 차감 없음
13:57:35  인증번호 439167 입니다     →  통과

자릿수가 맞는 숫자 토큰만 뽑아서 대조하고, 숫자가 아예 없는 문자는 시도로 세지 않아요. 같은 번호로 온 평범한 문자 때문에 멀쩡한 인증이 죽으면 안 되니까요. 대신 토큰에는 경계를 뒀어요. 경계가 없으면 「주문번호 1234567890 건」 안의 123456이 6자리 코드로 잡히거든요.

쓰시기 전에 확인하실 조건이 두 가지 있어요.

한국AI통신(ClawOps)에서 발급받은 번호여야 해요. 밖에서 가져온 번호는 수신 문자가 저희에게 들어오지 않아요. 발신형인 sms·call은 상관없고요.

from은 문자를 받아본 적 있는 번호로 지정하시는 편이 안전해요. 생략하면 계정 번호 중 하나가 자동으로 잡히는데, 갓 발급받은 070은 수신이 아직 열리지 않았을 수 있어요.

그리고 이 채널은 추가 요금이 없어요. 수신번호가 이미 고객님 번호라 번호 유지비를 내고 계시고, 저희가 더 받을 게 없거든요.

상황채널
일반적인 가입·로그인sms
문자가 안 가는 사용자call
입력란을 오가는 걸 줄이고 싶을 때sms_inbound

첫 연동은 sms 하나로 충분해요. 나머지는 channel 값만 바뀌니까 나중에 붙여도 구조를 고칠 일이 없습니다.

결과는 웹훅으로 받아요

call의 미응답과 sms_inbound의 통과는 웹훅으로만 알 수 있어요. 계정에 한 번 등록해두시면 됩니다.

curl -X POST https://api.claw-ops.com/v1/accounts/$ACCOUNT_ID/webhooks \
  -H "Authorization: Bearer $CLAWOPS_API_KEY" \
  -d '{
    "url": "https://my-app.com/hooks/verification",
    "events": ["verification.verified", "verification.failed", "verification.expired"]
  }'

오는 건 form-encoded POST예요. Reason에 call_no-answer 같은 사유가 실리고, 코드는 실리지 않아요. 대조는 저희가 끝냈으니 받는 쪽에서 코드로 하실 일이 없거든요.

Verify는 v2인데 웹훅은 v1 형식 그대로인 게 눈에 띄실 거예요. 일부러 그렇게 뒀어요. 웹훅 구독은 하나의 URL이 여러 이벤트를 함께 받으니까, 여기서 짝이 맞아야 하는 건 API 버전이 아니라 같은 엔드포인트로 들어오는 다른 이벤트들이에요. Verify만 JSON으로 내보내면 받는 쪽에서 Content-Type으로 분기하고 서명 검증도 두 벌로 갈라야 하죠.

서명 검증은 기존 웹훅과 같아요. HMAC-SHA256(signingKey, url + 정렬한 파라미터)를 Base64로 인코딩한 값이 X-Signature로 옵니다.

import crypto from "node:crypto";

function verifySignature(url, params, signature) {
  const data = url + Object.keys(params).sort().map((k) => k + params[k]).join("");
  const expected = crypto
    .createHmac("sha256", process.env.CLAWOPS_SIGNING_KEY)
    .update(data)
    .digest("base64");
  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

최소 1회 전달이라 같은 이벤트가 두 번 올 수 있고 순서도 보장하지 않아요. 결제나 계정 생성처럼 되돌리기 어려운 분기 앞에서는 GET /v2/verifications/{id}로 서버 상태를 한 번 더 보시는 편이 안전해요.

요금

인증 전용 요금은 0원이에요. 건당 인증료도, 인증만을 위한 월 기본료도 없어요. 쓰시던 요금제 안에서 문자 한 건, 통화 1분이 나가는 게 전부예요.

채널청구
sms문자 1건, 쓰시던 문자 단가 그대로
call발신 통화료, 미응답은 0원
sms_inbound없음

전용 미터를 따로 만들지 않았다는 뜻이기도 해요. 문자·통화 원장에 남는 걸 그대로 쓰니까, 요금제에 포함된 문자·통화량 안에서 인증도 같이 쓰시면 됩니다. 인증 건수를 따로 관리하실 필요가 없어요.

다만 두 가지는 알아두시면 좋아요. 재발송을 병존으로 열어둔 만큼 문자 건수가 곧 비용이라, 사용자가 재발송을 세 번 누르면 문자 3건이 찍혀요. 그리고 검증에 실패하거나 만료된 건도 문자는 이미 나갔으니 과금됩니다.

요금제별 문자·통화 단가는 요금 안내에 있어요.

지금은 없는 것

call_inbound(사용자가 전화를 거는 ARS 방식) 는 계약에는 정의돼 있지만 아직 열지 않았어요. 아직 안 연 채널은 channel enum에도 넣지 않았습니다. 「있는데 안 되는」 상태를 만들지 않으려고 validator가 앞에서 400으로 막아요. locale이 ko 하나뿐인 것도 같은 이유예요.

문자 문안과 음성 안내는 고정이에요. templateVariables로 서비스명만 앞에 붙일 수 있어요. 계정별 템플릿은 네 채널이 다 열린 뒤에 한 번에 열려고 미뤄뒀습니다.

verification.expired는 그 건을 조회하는 시점에 확정돼요. 별도 스윕이 없어서, 사용자가 아무것도 하지 않은 건은 만료 웹훅이 바로 오지 않아요. instruction.deadline까지 verification.verified가 오지 않으면 실패로 처리하시면 됩니다. 늦게 온 문자가 통과하는 일은 없어요. 매칭이 만료 시각을 조건에 걸고 있거든요.

시작해보세요

콘솔에서 API 키를 발급하고 번호가 하나 있으면 바로 쓸 수 있어요. 번호가 없으면 인증 생성이 422 no_number로 막혀요. 발신형은 그 번호로 나가고, 수신형은 그 번호로 받기 때문이에요.

「인증번호가 안 와요」 문의가 들어오면 콘솔의 인증 기록에서 한 건을 열어보세요. 대조 시도가 시각순으로 펼쳐져요. 언제 틀렸는지, 만료된 뒤에도 계속 넣었는지, 어느 API 키와 IP에서 왔는지까지 보입니다.

전체 필드와 에러 코드는 API 레퍼런스에 정리돼 있어요.

참고 링크

https://claw-ops.com

이전 글

GPT-Live로 AI 전화 상담원 만들기 — 듣는 동시에 말하는 전이중 모델을 070 번호에 코드 없이

한국AI통신(ClawOps) AI 전화 API로 시작하기

070 번호 발급부터 AI 음성 통화까지, REST API 몇 줄이면 됩니다.