# sendwich > Phone number verification through user-sent SMS. Website: https://sendwich.kr. Merchant API: https://api.sendwich.kr. Hosted verification window: https://id.it.kr. Users send the returned sms_text to destination from their phones. Integrations create, retrieve and cancel verifications through the server-side API and receive results by polling or signed webhook. Free and Plus both support the hosted window and direct API integration. ## 시작하기 - [sendwich 시작하기](https://sendwich.kr/docs/markdown/index.md): SMS 인증 흐름과 인증 창·API 연동 방식 - [첫 인증 만들기](https://sendwich.kr/docs/markdown/quickstart.md): 프로젝트 생성부터 첫 인증 결과 조회까지 ## 인증 이해하기 - [보내는 번호와 받는 번호](https://sendwich.kr/docs/markdown/phone-numbers.md): 발신 번호·수신 번호의 역할과 지원하는 전화번호 형식 - [인증 문자와 유효시간](https://sendwich.kr/docs/markdown/verification-codes.md): 인증 코드 형식, 유효시간, 문자 일치 규칙 - [인증 모드](https://sendwich.kr/docs/markdown/verification-modes.md): 발신 번호를 확인하는 discover와 지정한 번호를 검증하는 match ## 인증 창 - [인증 창 연결하기](https://sendwich.kr/docs/markdown/verification-window.md): 팝업, 완료 알림, 모바일·QR 전환 설정 - [인증 화면 설정](https://sendwich.kr/docs/markdown/verification-window/settings.md): 인증 창의 템플릿, QR 표시, 로고와 색상 설정 ## API - [API로 직접 연동하기](https://sendwich.kr/docs/markdown/api.md): 웹·앱·봇에서 인증 흐름을 직접 구현하는 방법 - [API 키와 인증](https://sendwich.kr/docs/markdown/api/authentication.md): 프로젝트 API 키 발급, 권한, 보관과 교체 - [인증 생성](https://sendwich.kr/docs/markdown/api/create.md): 인증 생성 요청·응답과 Idempotency-Key 사용 규칙 - [인증 조회와 취소](https://sendwich.kr/docs/markdown/api/retrieve-and-cancel.md): 인증 상태·결과 조회와 대기 중인 인증 취소 - [오류와 재시도](https://sendwich.kr/docs/markdown/api/errors.md): HTTP 상태별 오류 코드와 재시도 조건 ## 웹훅 - [웹훅](https://sendwich.kr/docs/markdown/webhooks.md): 인증 결과 수신, 서명 검증, 중복 이벤트 처리 ## 연동 예제 - [직접 만든 인증 화면](https://sendwich.kr/docs/markdown/examples/custom-ui.md): 사용자 화면과 서비스 서버의 구성 예제 - [Discord 봇](https://sendwich.kr/docs/markdown/examples/discord.md): 비공개 인증 안내와 역할 부여를 구현하는 Node.js 봇 - [Node.js와 Python](https://sendwich.kr/docs/markdown/examples/node-python.md): Node.js·Python 서버에서 인증을 생성하고 조회하는 예제 ## 운영하기 - [요금제와 사용량](https://sendwich.kr/docs/markdown/usage.md): 요금제별 기능, API 호출 집계, 월 사용량 한도 - [문제 해결](https://sendwich.kr/docs/markdown/troubleshooting.md): 문자 인증, 팝업 연결, 웹훅 수신 오류 해결 ## 도구 - [LLM용 문서](https://sendwich.kr/docs/markdown/llms.md): 코딩 도구용 Markdown 문서와 OpenAPI 명세 ## 활용 사례 - [휴대폰 인증 API](https://sendwich.kr/solutions/phone-verification-api/): 휴대폰 인증 API를 무료로 연동하는 방법. 인증 생성, 결과 조회, 웹훅까지 세 단계로 끝나고 계약과 심사가 필요 없어요. - [회원가입 휴대폰 인증](https://sendwich.kr/solutions/signup-verification/): 회원가입에 휴대폰 인증을 붙여 다계정 가입과 어뷰징을 막는 방법. 실명 확인 없이 번호 점유만 확인해요. - [디스코드 봇 인증](https://sendwich.kr/solutions/discord/): Discord 봇에서 휴대폰 인증을 받고 인증된 사용자에게 역할을 부여하는 방법. Node.js 예제와 함께 안내해요. - [휴대폰 인증 비용 비교](https://sendwich.kr/compare/free-phone-verification/): 휴대폰 인증에 드는 기본료, 건당 요금, 계약과 심사 기간을 비교하고 무료로 시작할 수 있는 조건을 정리했어요. ## Reference - [Merchant OpenAPI](https://sendwich.kr/docs/openapi.json): Request and response schemas for create, retrieve and cancel operations. - [All documentation](https://sendwich.kr/llms-full.txt): All articles in a single Markdown file. --- # sendwich 시작하기 > SMS 인증 흐름과 인증 창·API 연동 방식 문서: https://sendwich.kr/docs/ ## 인증 흐름 sendwich는 사용자가 보낸 SMS의 발신 번호로 휴대폰 번호를 확인합니다. 1. 서비스 서버에서 인증을 생성합니다. 2. 사용자에게 받는 번호와 인증 문자를 표시합니다. 3. 사용자가 자신의 휴대폰에서 문자를 전송합니다. 4. API 조회 또는 웹훅으로 인증 결과를 받습니다. 확인 범위는 휴대폰 번호의 점유입니다. 실명·생년월일·CI·DI를 제공하는 본인확인 서비스는 아닙니다. ## 연동 방식 | 방식 | 인증 화면 | 설정 | | --- | --- | --- | | [sendwich 인증 창](https://sendwich.kr/docs/verification-window/) | 제공되는 팝업·모바일 화면 | API 키, 팝업·복귀 주소(사용 시) | | [API 직접 연동](https://sendwich.kr/docs/api/) | 서비스의 웹·앱·봇에서 구현 | 서버용 API 키 | 두 방식 모두 Free와 Plus에서 사용할 수 있으며, 같은 프로젝트에서 함께 운영할 수 있습니다. 결과 수신에는 API 조회와 웹훅을 지원합니다. [첫 인증 만들기](https://sendwich.kr/docs/quickstart/)에서 프로젝트 생성부터 결과 조회까지 진행할 수 있습니다. ## 서비스 주소 | 역할 | 주소 | | --- | --- | | 홈페이지·대시보드·문서 | `https://sendwich.kr` | | 인증 API | `https://api.sendwich.kr` | | sendwich 인증 창 | `https://id.it.kr` | ## 주요 문서 - [보내는 번호와 받는 번호](https://sendwich.kr/docs/phone-numbers/): 발신 번호, 수신 번호, 지원 형식 - [인증 생성](https://sendwich.kr/docs/api/create/): 요청 필드, 응답, 중복 생성 방지 - [웹훅](https://sendwich.kr/docs/webhooks/): 이벤트 수신과 서명 검증 - [요금제와 사용량](https://sendwich.kr/docs/usage/): 기능 비교와 호출 집계 - [LLM용 문서](https://sendwich.kr/docs/llms/): Markdown과 OpenAPI 명세 --- # 첫 인증 만들기 > 프로젝트 생성부터 첫 인증 결과 조회까지 문서: https://sendwich.kr/docs/quickstart/ ## 1. 프로젝트 생성 1. [회원가입](https://sendwich.kr/signup/) 후 이메일 인증을 완료합니다. 2. [대시보드](https://sendwich.kr/dashboard/)에서 **프로젝트 만들기**를 선택하고 이름을 입력합니다. 3. 프로젝트의 **인증 체험**에서 문자를 보내 인증 결과를 확인합니다. 인증 체험은 실제 SMS 인증으로, 내역과 API 사용량에 반영되며 등록된 웹훅에도 결과를 전송합니다. 화면만 살펴보려면 [공개 데모](https://sendwich.kr/demo/)를 이용할 수 있습니다. ## 2. API 키 발급 프로젝트의 **API 키 → 새 키 만들기**에서 키를 발급합니다. 전체 키는 발급 시 한 번만 표시되므로 서버의 비밀 환경변수에 저장합니다. ```bash # 서버의 비밀 환경변수 또는 비밀 관리 도구에서 설정해요. export SENDWICH_API_KEY='YOUR_LIVE_SECRET_KEY' ``` ## 3. 인증 생성 새 인증마다 고유한 `Idempotency-Key`를 사용합니다. 같은 요청을 재시도할 때는 키와 본문을 유지합니다. ```bash curl 'https://api.sendwich.kr/v1/verifications' \ -H "Authorization: Bearer $SENDWICH_API_KEY" \ -H 'Idempotency-Key: quickstart_attempt_001' \ -H 'Content-Type: application/json' \ --data '{"mode":"discover","client_reference":"attempt_001"}' ``` 응답의 `id`를 서버에 저장하고, 인증을 시작한 사용자 또는 가입 시도와 연결합니다. `client_reference`는 서비스에서 정한 시도 식별자입니다. ## 4. 문자 전송과 결과 조회 응답의 `verification_url`을 열거나, 직접 만든 화면에 `destination`, `sms_text`, `expires_at`을 표시합니다. 사용자가 안내된 번호로 인증 문자를 보내면 다음 API로 결과를 조회합니다. ```bash # vrf_EXAMPLE을 방금 받은 id로 바꿔요. curl 'https://api.sendwich.kr/v1/verifications/vrf_EXAMPLE' \ -H "Authorization: Bearer $SENDWICH_API_KEY" ``` | `status` | 처리 | | --- | --- | | `verified` | `phone`에서 확인된 번호 조회 | | `pending` | 문자 수신 대기 | | `expired`, `cancelled` | 새 인증 시작 | 자동으로 결과를 받으려면 [웹훅](https://sendwich.kr/docs/webhooks/)을 설정합니다. 조회와 재시도는 각각 API 호출로 집계됩니다. ## 연동 예제 - [인증 창 연결하기](https://sendwich.kr/docs/verification-window/): 팝업과 모바일 전환 - [직접 만든 인증 화면](https://sendwich.kr/docs/examples/custom-ui/): 서비스 화면에서 인증 구현 - [Discord 봇](https://sendwich.kr/docs/examples/discord/): 비공개 메시지와 역할 부여 - [Node.js와 Python](https://sendwich.kr/docs/examples/node-python/): 서버 API 호출 --- # 보내는 번호와 받는 번호 > 발신 번호·수신 번호의 역할과 지원하는 전화번호 형식 문서: https://sendwich.kr/docs/phone-numbers/ ## 발신 번호 발신 번호는 인증 문자를 보낸 사용자의 휴대폰 번호입니다. 사용자는 확인하려는 번호의 SIM으로 문자를 전송해야 합니다. 듀얼 SIM 기기에서는 문자 앱에서 선택한 회선의 번호가 확인됩니다. 문자 발송 요금은 사용자의 통신사 요금제에 따라 발생할 수 있습니다. ## 수신 번호 수신 번호는 사용자가 문자를 보낼 sendwich의 번호로, 인증 생성 응답의 `destination`을 사용합니다. ```javascript const receivingNumber = session.destination; const messageToSend = session.sms_text; ``` 화면에 번호를 표시할 때는 하이픈을 넣을 수 있습니다. 복사와 문자 앱 연결에는 응답의 원본 값을 사용합니다. 수신 번호는 sendwich에서 관리합니다. ## 지원 형식 대한민국 `010` 휴대폰 번호를 지원합니다. `match` 모드의 `expected_phone`에는 `01012345678`, `010-1234-5678`, `+821012345678` 형식을 사용할 수 있습니다. 인증 결과는 E.164 형식인 `+821012345678`로 반환합니다. | 필드 | 의미 | | --- | --- | | `destination` | 인증 문자를 보낼 수신 번호 | | `expected_phone` | `match` 모드에서 확인할 휴대폰 번호 | | `phone` | 인증 성공 후 API·웹훅으로 반환하는 발신 번호 | | `carrier` | 수신 문자에서 확인한 통신망: `SKT`, `KT`, `LGU+` 또는 `null` | `carrier`는 통신망 정보이며 알뜰폰 판매 브랜드를 구분하지 않습니다. 통신망 정보가 없는 경우에도 번호 인증은 가능합니다. 번호를 사전에 입력받을지는 [인증 모드](https://sendwich.kr/docs/verification-modes/)에서 결정합니다. 전체 번호는 서버 API·웹훅으로 제공하며, 인증 창의 브라우저 API에서는 마스킹한 번호를 제공합니다. --- # 인증 문자와 유효시간 > 인증 코드 형식, 유효시간, 문자 일치 규칙 문서: https://sendwich.kr/docs/verification-codes/ ## 인증 문자 인증 생성 응답의 `sms_text`는 사용자가 보낼 전체 문자입니다. `하늘바다`가 반환되었다면 해당 네 글자를 전송합니다. 코드는 인증마다 발급되며, 숫자 코드도 앞자리 `0`이 유지되도록 문자열로 처리합니다. ## 코드 형식 대시보드의 **프로젝트 설정 → 인증 코드**에서 설정합니다. | 형식 | 예시 | 길이 | 요금제 | | --- | --- | --- | --- | | 한글 단어 | `하늘바다` | 4글자 | Free·Plus | | 영문 대문자 | `KRMXQ` | 4~12자 | Plus | | 숫자 | `004281` | 4~12자 | Plus | 영문은 `I`, `O`를 제외합니다. 프로젝트의 코드 설정은 인증 창과 API 직접 연동에 동일하게 적용됩니다. ## 유효시간 Free와 Plus 모두 **60~900초**로 설정할 수 있습니다. 만료 시각은 응답의 `expires_at`이며, 남은 시간은 `server_time`을 기준으로 계산합니다. 최종 만료 여부는 서버에서 판정합니다. 설정 변경은 이후 생성하는 인증부터 적용됩니다. 같은 `Idempotency-Key`로 재시도하면 기존 코드와 만료 시각을 반환합니다. ## 문자 일치 규칙 앞뒤 공백, 영문 소문자, 분해형 한글은 정규화합니다. 코드 중간의 공백, 추가 문장, 전각 문자가 포함되면 일치하지 않습니다. SMS 수신 처리는 만료 시각 전에 완료되어야 합니다. 짧은 숫자 코드는 사용 가능한 조합이 적습니다. `503 code_unavailable`이 반복되면 코드 길이를 늘리거나 한글 형식으로 변경합니다. --- # 인증 모드 > 발신 번호를 확인하는 discover와 지정한 번호를 검증하는 match 문서: https://sendwich.kr/docs/verification-modes/ ## 모드 선택 | 모드 | 확인 대상 | 사용 예 | | --- | --- | --- | | `discover` | 인증 문자를 보낸 번호 | 회원가입, 봇 인증 | | `match` | 지정한 번호와 실제 발신 번호의 일치 여부 | 기존 번호 확인, 번호 변경 | 모드는 인증 창과 API 직접 연동에서 동일하게 동작합니다. 요청 필드의 조건은 [인증 생성](https://sendwich.kr/docs/api/create/)을 참고합니다. ## discover 인증 문자를 보낸 번호를 결과의 `phone`으로 반환합니다. ```json { "mode": "discover", "client_reference": "attempt_93a7" } ``` 코드를 전달받은 다른 사람도 자신의 번호로 인증을 완료할 수 있으므로, 코드와 링크는 인증을 시작한 사용자에게만 표시합니다. ## match `expected_phone`에 지정한 번호에서 인증 문자를 수신해야 완료됩니다. 다른 번호에서 같은 문자를 보내면 `pending` 상태를 유지합니다. ```json { "mode": "match", "expected_phone": "010-1234-5678", "client_reference": "attempt_93a7" } ``` ## 계정에 번호 연결 인증 ID와 서비스의 사용자·시도를 서버에 저장하고, [조회 결과](https://sendwich.kr/docs/api/retrieve-and-cancel/) 또는 [웹훅](https://sendwich.kr/docs/webhooks/)과 대조한 뒤 번호를 연결합니다. 한 번호의 다중 계정 사용 허용 여부는 서비스에서 정합니다. --- # 인증 창 연결하기 > 팝업, 완료 알림, 모바일·QR 전환 설정 문서: https://sendwich.kr/docs/verification-window/ ## 인증 창 구성 sendwich 인증 창은 `https://id.it.kr`에서 제공하며, 생성 응답의 `verification_url`로 엽니다. PC의 QR 코드, 모바일 문자 앱 연결, 남은 시간과 완료·취소·만료 화면을 지원합니다. ## 팝업·복귀 주소 등록 프로젝트 설정의 **인증 창 연결 설정 · 선택**에서 서비스의 주소를 등록합니다. | 설정 | 용도 | 등록 예시 | | --- | --- | --- | | 허용 도메인 | 팝업 완료 알림을 받을 부모 페이지의 출처 | `https://shop.example.com` | | 인증 후 돌아갈 URL | 같은 탭에서 인증한 뒤 돌아갈 페이지 | `https://shop.example.com/verification/return` | 허용 도메인은 경로 없이 스킴·호스트·포트로 구성합니다. 복귀 URL은 경로까지 정확히 등록합니다. 실제 SMS 프로젝트에는 HTTPS가 필요하며, 와일드카드와 `#`은 허용하지 않습니다. 인증 생성 시 사용할 주소를 `parent_origin`, `return_url`로 전달합니다. 두 필드는 선택 사항이며, 프로젝트에 등록한 값과 일치해야 합니다. ```json { "mode": "discover", "client_reference": "attempt_93a7", "state": "SERVER_GENERATED_RANDOM_STATE", "parent_origin": "https://shop.example.com", "return_url": "https://shop.example.com/verification/return" } ``` ## 팝업 열기 사용자 클릭 이벤트에서 빈 창을 먼저 열고, 인증 생성 응답을 받은 뒤 해당 창으로 이동합니다. `/api/phone-verifications`는 서비스 서버에 구현할 경로입니다. 서버는 사용자와 인증 ID를 연결해 저장하고, 화면에 필요한 값을 반환합니다. ```javascript async function startVerification() { const popup = window.open('', 'veri', 'popup,width=460,height=760'); try { const response = await fetch('/api/phone-verifications', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ attempt: crypto.randomUUID() }) }); if (!response.ok) throw new Error('인증을 시작하지 못했어요.'); const session = await response.json(); // 아래의 메시지 핸들러를 먼저 설치한 뒤 창을 이동해요. watchVerification(popup, session); if (popup) popup.location.href = session.verification_url; else window.location.assign(session.verification_url); } catch (error) { popup?.close(); throw error; } } ``` ## 완료 알림 `parent_origin`을 지정하면 인증 창이 부모 페이지에 다음 형식의 `postMessage`를 전송합니다. ```json { "type": "veri.verification.result", "id": "vrf_EXAMPLE", "status": "verified", "state": "SERVER_GENERATED_RANDOM_STATE" } ``` 출처, 팝업 핸들, 인증 ID, `state`가 일치하는 알림을 받으면 서버에서 결과를 조회합니다. 예제의 `refreshResultFromYourServer`는 서비스 서버에서 결과를 가져오는 함수입니다. ```javascript function watchVerification(popup, session) { if (!popup) return; const handler = async (event) => { if (event.origin !== 'https://id.it.kr' || event.source !== popup) return; const data = event.data; if (data?.type !== 'veri.verification.result' || data.id !== session.id || data.state !== session.state) return; window.removeEventListener('message', handler); await refreshResultFromYourServer(session.id); }; window.addEventListener('message', handler); // 페이지를 떠나거나 인증이 만료되면 핸들러도 제거해요. return () => window.removeEventListener('message', handler); } ``` ## 모바일과 QR 모바일에서는 문자 앱을 열어 사용자가 직접 전송합니다. 본문 자동 입력을 지원하지 않는 기기를 위해 번호와 문자 복사 기능을 제공합니다. PC의 QR 코드로 휴대폰에서 열어도 동일한 인증 세션을 사용합니다. 부모 창이 없으면 완료 화면의 버튼으로 등록된 복귀 URL에 이동합니다. URL에는 `verification_id`와 `state`가 쿼리로 추가됩니다. 복귀한 페이지는 이 값을 서버에 저장한 시도와 대조하고, API 조회나 웹훅으로 최종 결과를 확인합니다. --- # 인증 화면 설정 > 인증 창의 템플릿, QR 표시, 로고와 색상 설정 문서: https://sendwich.kr/docs/verification-window/settings/ ## 설정 위치 프로젝트의 **인증 화면**에서 미리보기와 설정 저장을 할 수 있습니다. 변경 사항은 이후 생성하는 인증부터 적용됩니다. 코드 형식과 유효시간은 **프로젝트 설정**에서 관리합니다. ## 요금제별 설정 | 설정 | Free | Plus | | --- | --- | --- | | 기본 인증 창 | 제공 | 제공 | | PC QR 표시 | 변경 가능 | 변경 가능 | | 기본형·컴팩트·브랜드형·밤티 템플릿 | 기본형 사용, 다른 템플릿 미리보기 | 선택·저장 가능 | | 로고·색상·안내 문구 | 기본 설정 | 변경 가능 | | 워터마크 제거 | 미제공 | 제공 | **PC 제목·PC 안내 문구**와 **모바일 제목·모바일 안내 문구**를 각각 설정할 수 있습니다. 비워 둔 항목은 해당 기기의 템플릿 기본 문구를 사용합니다. 컴팩트와 밤티는 모바일 기본 제목과 안내 문구가 없어, PC 문구만 변경해도 모바일에 추가되지 않습니다. 기존에 저장한 공통 문구는 두 기기의 설정에 표시되며 각각 수정하거나 비울 수 있습니다. 로고는 인증 화면 하단에서 `내 서비스에도 도입하기` 링크 아래 줄에 가운데 정렬로 표시됩니다. **워터마크 제거**를 켜면 이 링크와 기본 sendwich 로고를 숨깁니다. 업로드한 로고는 하단에 유지됩니다. ## 템플릿과 QR 모든 템플릿은 같은 화면 구성을 사용합니다. 남은 시간은 PC에서 QR 코드 바로 아래에, QR이 없으면 화면 위쪽에 표시됩니다. 모바일에서는 화면 맨 아래 `인증 취소` 밑에 표시됩니다. 인증 문자와 받는 번호는 **복사** 버튼이 있는 두 줄로 표시됩니다. 모바일에서 문자 앱을 연 뒤에는 버튼 자리에 문자 도착을 확인하는 상태가 표시됩니다. 기본형은 인증 문자와 받는 번호를 바로 표시합니다. 컴팩트는 같은 구성을 더 좁은 간격으로, 글을 가운데 정렬해 표시합니다. 모바일에서는 제목·안내 문구와 **문자 보내기** 버튼이 한 덩어리로 화면 가운데에 놓이고, 아래쪽에 인증 취소와 남은 시간이 표시됩니다. 이 화면에는 **직접 보내기**가 없으므로 인증 문자와 받는 번호는 표시되지 않습니다. 브랜드형은 상단 영역과 버튼에 설정한 색상을 사용합니다. QR 표시 설정은 PC 화면에 적용되며, 모바일에서는 문자 앱 연결을 제공합니다. 밤티는 흰 배경, 각진 테두리와 돋움 계열 글꼴을 사용한 간결한 스타일입니다. PC에서는 QR을 먼저 표시하고, 인증 문자와 받는 번호는 **직접 보내기**를 열면 확인할 수 있습니다. PC QR을 끄면 해당 정보가 바로 표시됩니다. 모바일에서는 문자 앱 연결을 제공하며, **직접 보내기**로 인증 문자와 받는 번호를 확인할 수 있습니다. ## 요금제 변경 Plus에서 저장한 설정은 Free로 변경해도 보존됩니다. 변경 이후 생성하는 인증에는 Free 설정이 적용되고, 진행 중인 인증은 기존 설정을 유지합니다. 자체 웹·앱·봇의 화면은 [API 직접 연동](https://sendwich.kr/docs/api/)으로 구현합니다. 인증 창의 템플릿 설정은 해당 화면에 적용되지 않습니다. --- # API로 직접 연동하기 > 웹·앱·봇에서 인증 흐름을 직접 구현하는 방법 문서: https://sendwich.kr/docs/api/ ## 연동 흐름 웹사이트, 앱, Discord 봇에서 인증 화면과 결과 처리를 직접 구현할 수 있습니다. Free와 Plus 모두 지원합니다. 1. 서버에서 `POST https://api.sendwich.kr/v1/verifications`를 호출합니다. 2. 응답의 `destination`, `sms_text`, `expires_at`을 사용자에게 표시합니다. 3. 사용자가 자신의 휴대폰에서 인증 문자를 전송합니다. 4. 서버에서 [웹훅](https://sendwich.kr/docs/webhooks/) 또는 [조회 API](https://sendwich.kr/docs/api/retrieve-and-cancel/)로 결과를 받습니다. ## 연결 설정 API 직접 연동에서는 생성 요청의 `parent_origin`과 `return_url`을 생략합니다. 프로젝트의 **허용 도메인**과 **인증 후 돌아갈 URL**도 비워 둘 수 있습니다. 두 설정은 [인증 창의 팝업 알림과 복귀 이동](https://sendwich.kr/docs/verification-window/)에 사용됩니다. API 조회로 결과를 받는 봇은 공개 URL 없이 운영할 수 있습니다. 웹훅을 사용하려면 외부에서 접근 가능한 HTTPS 수신 주소가 필요합니다. ## 서버와 화면 구성 | 위치 | 역할 | | --- | --- | | 서비스 서버·봇 프로세스 | API 키 보관, 인증 생성, 사용자와 인증 ID 연결, 결과 처리 | | 웹·앱 화면·비공개 봇 메시지 | 받는 번호, 인증 문자, 만료 시각 표시 | | sendwich | SMS 수신, 발신 번호 확인, 인증 상태 관리, 웹훅 전송 | 클라이언트는 서비스 서버를 통해 인증을 생성하고 조회합니다. 서버는 로그인한 사용자가 자신의 인증에만 접근하도록 제한합니다. 자세한 구성은 [직접 만든 인증 화면](https://sendwich.kr/docs/examples/custom-ui/)을 참고합니다. ## API 목록 | 메서드 | 경로 | 기능 | | --- | --- | --- | | POST | `/v1/verifications` | [인증 생성](https://sendwich.kr/docs/api/create/) | | GET | `/v1/verifications/{id}` | [인증 조회](https://sendwich.kr/docs/api/retrieve-and-cancel/) | | POST | `/v1/verifications/{id}/cancel` | [인증 취소](https://sendwich.kr/docs/api/retrieve-and-cancel/) | ## 결과 수신 웹훅은 인증의 최종 상태를 서버에 전송합니다. 조회 API는 주기적으로 호출하거나 사용자가 **인증 확인**을 누를 때 호출할 수 있습니다. 주기적으로 조회할 경우 최종 상태에 도달하거나 만료 시각이 지나면 중단합니다. 조회와 재시도는 각각 [API 사용량](https://sendwich.kr/docs/usage/)에 포함됩니다. 실행 코드는 [Node.js·Python](https://sendwich.kr/docs/examples/node-python/)과 [Discord 봇](https://sendwich.kr/docs/examples/discord/) 예제에서 확인할 수 있습니다. --- # API 키와 인증 > 프로젝트 API 키 발급, 권한, 보관과 교체 문서: https://sendwich.kr/docs/api/authentication/ ## 키 발급 프로젝트의 **API 키 → 새 키 만들기**에서 발급합니다. 실제 SMS 프로젝트의 키는 `veri_live_`로 시작하며, 전체 값은 발급 시 한 번만 표시됩니다. ```http Authorization: Bearer YOUR_LIVE_SECRET_KEY ``` 키는 서버의 비밀 환경변수에 보관합니다. 브라우저에 공개되는 `NEXT_PUBLIC_`, `VITE_` 환경변수에는 저장하지 않습니다. ## 프로젝트와 권한 API 키가 속한 프로젝트와 환경에서 인증을 생성·조회·취소할 수 있습니다. | 권한 | 기능 | | --- | --- | | `verifications:create` | 인증 생성 | | `verifications:read` | 인증 상태와 결과 조회 | | `verifications:cancel` | 인증 취소 | 대시보드에서 발급하는 키에는 세 권한이 모두 포함됩니다. 권한이 부족한 요청은 `403 insufficient_scope`를 반환하며, 폐기된 키로는 API를 호출할 수 없습니다. ## 키와 토큰의 용도 | 항목 | 용도 | 보관 위치 | | --- | --- | --- | | 프로젝트 API 키 | API 요청 인증 | 서비스 서버 | | 웹훅 서명 키 (`whsec_…`) | 수신한 이벤트의 서명 검증 | 웹훅 수신 서버 | | 인증 창 URL의 세션 토큰 | 해당 인증 창 접근 | 인증을 시작한 사용자에게 전달 | 인증 코드, 세션 토큰, 전체 전화번호는 공개 메시지나 운영 로그에 기록하지 않습니다. `state`와 `client_reference`에는 개인정보 대신 임의의 식별자를 사용합니다. ## 키 교체 새 키 발급 → 서버에 적용 → API 요청 확인 → 이전 키 폐기 순서로 교체합니다. 기존 인증은 같은 프로젝트의 새 키로 조회할 수 있습니다. 분실한 키는 재발급하고, 외부에 노출된 키는 즉시 폐기합니다. --- # 인증 생성 > 인증 생성 요청·응답과 Idempotency-Key 사용 규칙 문서: https://sendwich.kr/docs/api/create/ ## 요청 ```http POST https://api.sendwich.kr/v1/verifications Authorization: Bearer YOUR_LIVE_SECRET_KEY Idempotency-Key: attempt_93a7_create Content-Type: application/json ``` ```json { "mode": "discover", "client_reference": "attempt_93a7", "state": "SERVER_GENERATED_RANDOM_STATE" } ``` | 필드 | 기본값·조건 | 설명 | | --- | --- | --- | | `mode` | `discover` | `discover` 또는 `match` | | `expected_phone` | `match` 전용·필수 | 일치 여부를 확인할 010 휴대폰 번호 | | `client_reference` | 빈 문자열 | 서비스의 시도 식별자, 최대 160자 | | `state` | 빈 문자열 | 서버에서 생성한 무작위 연결 값, 최대 256자 | | `parent_origin` | 선택 | 팝업 알림을 받을 출처. 프로젝트 등록값과 일치해야 함 | | `return_url` | 선택 | 인증 후 돌아갈 URL. 프로젝트 등록값과 일치해야 함 | 프로젝트와 환경은 API 키로 결정됩니다. 코드 형식과 유효시간은 [프로젝트 설정](https://sendwich.kr/docs/verification-codes/)을 따릅니다. 정의되지 않은 필드는 거절합니다. ## 응답 최초 생성은 `201`, 동일 요청의 재시도는 `200`을 반환합니다. ```json { "id": "vrf_EXAMPLE", "status": "pending", "environment": "live", "mode": "discover", "phone": null, "carrier": null, "client_reference": "attempt_93a7", "state": "SERVER_GENERATED_RANDOM_STATE", "created_at": "2026-09-16T03:00:00.000Z", "expires_at": "2026-09-16T03:05:00.000Z", "verified_at": null, "server_time": "2026-09-16T03:00:00.000Z", "verification_url": "https://id.it.kr/#/verify/SESSION_TOKEN", "destination": "16661629", "sms_text": "하늘바다" } ``` | 필드 | 설명 | | --- | --- | | `id` | 인증 ID. 서버에서 사용자·시도와 연결해 저장 | | `status` | 최초 생성 시 `pending` | | `environment` | 실제 SMS 프로젝트는 `live`, 기존 테스트 프로젝트는 `test` | | `mode`, `client_reference`, `state` | 요청한 인증 모드와 연결 값 | | `phone`, `carrier`, `verified_at` | 최초 생성 시 `null` | | `created_at`, `expires_at`, `server_time` | UTC ISO 8601 시각 | | `verification_url` | sendwich 인증 창 URL | | `destination` | 인증 문자를 보낼 수신 번호 | | `sms_text` | 사용자가 보낼 전체 문자 | 사용자에게 안내할 번호와 문자는 응답값을 사용합니다. 인증 창을 사용하는 경우 `verification_url`을 엽니다. ## 중복 생성 방지 `Idempotency-Key`는 영문, 숫자, `_`, `.`, `:`, `-`로 구성한 8~128자의 필수 헤더입니다. 응답을 받지 못했을 때는 **같은 키와 같은 본문**으로 재시도합니다. 같은 키로 본문을 변경하면 `409 idempotency_conflict`를 반환합니다. 재시도 응답에는 `Idempotent-Replayed: true` 헤더와 최초 생성 응답이 포함됩니다. 인증 ID, 코드, URL, 만료 시각이 유지되며 유효시간은 연장되지 않습니다. 생성 응답은 24시간 보존됩니다. 보존 기간 이후에는 같은 키로 새 인증이 생성될 수 있습니다. 별도의 인증 시도를 시작할 때는 새 키를 사용합니다. 현재 상태는 [조회 API](https://sendwich.kr/docs/api/retrieve-and-cancel/)로 확인합니다. 이미 완료된 인증도 생성 요청을 재시도하면 최초의 `pending` 응답을 반환합니다. 재시도 요청도 API 사용량에 포함됩니다. ## 웹훅 적용 시점 인증을 생성할 때 프로젝트의 웹훅 설정이 저장됩니다. 알림을 받으려면 생성 전에 [웹훅 수신 설정](https://sendwich.kr/docs/webhooks/)을 완료해야 합니다. --- # 인증 조회와 취소 > 인증 상태·결과 조회와 대기 중인 인증 취소 문서: https://sendwich.kr/docs/api/retrieve-and-cancel/ ## 인증 조회 인증이 속한 프로젝트의 API 키와 `verifications:read` 권한이 필요합니다. 성공 시 `200`을 반환합니다. ```bash curl 'https://api.sendwich.kr/v1/verifications/vrf_EXAMPLE' \ -H "Authorization: Bearer $SENDWICH_API_KEY" ``` ```json { "id": "vrf_EXAMPLE", "status": "verified", "environment": "live", "mode": "discover", "phone": "+821012345678", "carrier": "SKT", "client_reference": "attempt_93a7", "state": "SERVER_GENERATED_RANDOM_STATE", "created_at": "2026-09-16T03:00:00.000Z", "expires_at": "2026-09-16T03:05:00.000Z", "verified_at": "2026-09-16T03:01:00.000Z", "server_time": "2026-09-16T03:01:02.000Z" } ``` `sms_text`, `destination`, `verification_url`은 생성 응답에서만 제공하므로 인증이 진행되는 동안 필요한 값을 보관합니다. ## 상태와 결과 | 상태 | 의미 | 처리 | | --- | --- | --- | | `pending` | 인증 대기 중 | 웹훅 대기 또는 조회 | | `verified` | 발신 번호 확인 완료 | 사용자·시도 대조 후 결과 반영 | | `expired` | 유효시간 경과 | 새 인증 시작 | | `cancelled` | 인증 취소 | 새 인증 시작 | `verified`, `expired`, `cancelled`는 변경되지 않는 최종 상태입니다. 인증이 성공하면 `phone`에 E.164 형식의 번호, `verified_at`에 완료 시각을 반환합니다. 그 외에는 두 값이 `null`입니다. `carrier`는 `SKT`, `KT`, `LGU+` 또는 `null`이며 성공 여부와 독립적입니다. 서버에 저장한 사용자·시도와 `id`, `environment`, `mode`, `client_reference`, `state`를 대조합니다. 실제 번호 인증에는 `live` 결과를 반영하고, 후속 작업은 인증 ID당 한 번만 처리합니다. ## 인증 취소 ```bash curl -X POST 'https://api.sendwich.kr/v1/verifications/vrf_EXAMPLE/cancel' \ -H "Authorization: Bearer $SENDWICH_API_KEY" ``` `verifications:cancel` 권한이 필요하며 요청 본문은 생략합니다. 성공 시 `200`과 조회 API와 같은 형식의 결과를 반환합니다. 취소는 `pending` 상태에만 적용됩니다. 이미 최종 상태라면 해당 상태를 반환합니다. 문자 수신과 취소 요청이 동시에 처리될 경우 먼저 확정된 상태를 반환하므로 응답의 `status`를 확인해야 합니다. ## 조회와 결과 보관 최종 상태에 도달하거나 만료 시각이 지나면 주기적인 조회를 중단합니다. 일시적인 통신 오류에는 조회 간격을 늘립니다. 호출을 줄이려면 [웹훅](https://sendwich.kr/docs/webhooks/)이나 사용자 확인 버튼을 사용할 수 있습니다. 조회는 요청마다 [사용량](https://sendwich.kr/docs/usage/)에 포함됩니다. 서비스에 필요한 최종 결과와 처리 내역은 자체 데이터베이스에 보관합니다. sendwich의 보존 기간이 지난 인증 ID는 `404`를 반환할 수 있습니다. --- # 오류와 재시도 > HTTP 상태별 오류 코드와 재시도 조건 문서: https://sendwich.kr/docs/api/errors/ ## 오류 형식 ```json { "error": { "code": "idempotency_conflict", "message": "...", "request_id": "REQUEST_ID" } } ``` HTTP 상태와 `error.code`로 오류를 구분합니다. 입력 검증 오류에는 `fields`가 추가될 수 있습니다. 문의 시 `X-Request-ID` 또는 `error.request_id`를 전달하면 요청을 식별할 수 있습니다. ## 오류 코드 | HTTP | 코드 | 조치 | | --- | --- | --- | | 400 | `invalid_idempotency_key` | 허용된 문자로 8~128자의 키 생성 | | 401 | `authentication_required`, `invalid_api_key` | Bearer 헤더와 API 키 확인 | | 403 | `insufficient_scope`, `key_revoked` | 키 권한 확인 또는 새 키 발급 | | 403 | `project_inactive`, `email_unverified` | 프로젝트 상태·이메일 인증 확인 | | 404 | `not_found` | 인증 ID, 소속 프로젝트, 보존 기간 확인 | | 409 | `idempotency_conflict` | 기존 본문으로 재시도하거나 새 시도에 새 키 사용 | | 422 | `invalid_request`, `invalid_url`, `invalid_origin`, `invalid_return_url` | 요청 필드와 프로젝트의 URL 등록값 확인 | | 429 | `monthly_call_limit` | 다음 월 집계 시작 또는 요금제 변경 후 재개 | | 503 | `service_unavailable`, `sms_reader_unavailable` | 대기 시간을 늘려 재시도 | | 503 | `live_not_enabled`, `destination_unconfigured` | 서비스 연결 상태 문의 | | 503 | `code_unavailable` | 재시도 후 반복되면 코드 형식·길이 변경 | ## 재시도 타임아웃이 발생해도 서버에서 인증이 생성되었을 수 있습니다. 생성 결과가 불확실하면 기존 `Idempotency-Key`와 본문을 유지해 재시도합니다. 실패가 반복되면 대기 시간을 늘리고 최대 횟수를 제한합니다. `429 monthly_call_limit`은 월 허용량을 소진한 상태입니다. 짧은 간격으로 재시도해도 복구되지 않습니다. 실패한 요청과 재시도도 [API 사용량](https://sendwich.kr/docs/usage/)에 포함됩니다. 조회 오류는 인증 결과가 아직 확인되지 않은 상태로 처리합니다. 이후 조회 또는 웹훅으로 최종 상태를 확인합니다. --- # 웹훅 > 인증 결과 수신, 서명 검증, 중복 이벤트 처리 문서: https://sendwich.kr/docs/webhooks/ ## 수신 설정 프로젝트의 **웹훅**에서 수신 URL을 등록하고 사용을 켠 뒤 저장합니다. 수신 주소는 `https://shop.example.com/webhooks/sendwich`와 같이 서비스 서버에 구현합니다. 실제 SMS 프로젝트에는 외부에서 접근 가능한 HTTPS 주소와 443 포트가 필요합니다. localhost, 사설망 주소, 리디렉션은 지원하지 않습니다. 처음 저장할 때 표시되는 `whsec_…` 서명 키를 서버에 보관합니다. 설정은 **저장 후 생성한 인증부터** 적용됩니다. ## 이벤트 | 이벤트 | 상태 | | --- | --- | | `verification.verified` | 인증 성공 | | `verification.expired` | 인증 만료 | | `verification.cancelled` | 인증 취소 | 각 인증은 하나의 최종 이벤트를 생성합니다. 같은 이벤트가 재전송될 수 있으므로 중복 실행을 방지해야 합니다. ```json { "id": "evt_EXAMPLE", "type": "verification.verified", "created_at": "2026-09-16T03:01:00.000Z", "data": { "id": "vrf_EXAMPLE", "status": "verified", "environment": "live", "mode": "discover", "phone": "+821012345678", "carrier": "SKT", "client_reference": "attempt_93a7", "state": "SERVER_GENERATED_RANDOM_STATE", "expires_at": "2026-09-16T03:05:00.000Z", "verified_at": "2026-09-16T03:01:00.000Z" } } ``` `data`에는 조회 응답의 `created_at`, `server_time`이 포함되지 않습니다. `carrier`는 `null`일 수 있으며, 이전 이벤트에는 필드가 없을 수 있습니다. ## 서명 검증 이벤트 ID는 `Veri-Event-ID`, 서명은 `Veri-Signature: t=UNIX_SECONDS,v1=HEX_HMAC` 헤더로 전달됩니다. 1. JSON 파싱 전 **원본 HTTP 본문 바이트**를 확보합니다. 2. 서명 시각과 서버 시각의 차이가 5분 이내인지 확인합니다. 3. `timestamp + "." + raw_body`에 대해 HMAC-SHA256을 계산합니다. 키는 `whsec_` 접두사를 포함한 전체 문자열입니다. 4. 상수 시간 비교로 서명을 확인한 뒤 JSON을 파싱합니다. 5. 헤더와 본문의 이벤트 ID, 이벤트 종류와 상태, 서버에 저장한 인증 시도를 대조합니다. [Node.js 서명 검증 함수 다운로드](https://sendwich.kr/docs/downloads/webhook-signature.mjs) ```javascript import { createHmac, timingSafeEqual } from 'node:crypto'; export function verifyWebhook(rawBody, signature, secrets, now = Date.now()) { if (!Buffer.isBuffer(rawBody) || rawBody.length > 65536) throw new Error('Invalid body.'); const match = /^t=(\d{1,12}),v1=([a-f0-9]{64})$/.exec(signature || ''); if (!match || Math.abs(now / 1000 - Number(match[1])) > 300) { throw new Error('Invalid or expired signature.'); } const provided = Buffer.from(match[2], 'hex'); const keys = Array.isArray(secrets) ? secrets : [secrets]; const valid = keys.filter(key => typeof key === 'string' && key.startsWith('whsec_')).some(key => { const expected = createHmac('sha256', key) .update(match[1] + '.').update(rawBody).digest(); return timingSafeEqual(provided, expected); }); if (!valid) throw new Error('Invalid signature.'); const event = JSON.parse(rawBody.toString('utf8')); const statuses = ['verified', 'expired', 'cancelled']; if (!event || typeof event.id !== 'string' || !event.id.startsWith('evt_') || !event.data || !statuses.includes(event.data.status) || event.type !== 'verification.' + event.data.status) { throw new Error('Invalid event.'); } // The receiver must still compare Veri-Event-ID to event.id, bind event.data // to its stored attempt, check live/mode/state/reference, and deduplicate in DB. return event; } ``` Express 등에서 JSON 미들웨어를 사용한다면 이 경로에서는 파싱 전에 원문을 받아야 합니다. `JSON.stringify(req.body)`로 재구성하면 원문이 달라져 서명 검증에 실패할 수 있습니다. 수신 본문의 크기도 제한합니다. ## 결과 저장과 중복 처리 서명 검증 후 `data.id`, `state`, `client_reference`, `environment`, `mode`를 서버에 저장한 시도와 대조합니다. 이벤트 ID에 고유 제약을 두고 이벤트 저장과 후속 작업 예약을 하나의 트랜잭션으로 처리합니다. 저장이 완료되면 `2xx`를 반환하고, 역할 부여나 이메일 발송은 별도 작업으로 실행합니다. 이미 저장한 이벤트는 중복 실행 없이 `2xx`를 반환합니다. 저장에 실패한 경우에는 오류 응답을 반환해 재전송을 받습니다. API 조회와 웹훅을 함께 사용한다면 인증 ID에도 중복 처리 방지를 적용해야 합니다. ## 재전송 전송 실패 시 5초부터 최대 1시간까지 대기 시간을 늘리며 재시도합니다. 전송 기한은 24시간, 최대 시도 횟수는 20회입니다. 재시도 시 서명 시각은 갱신되며 이벤트 ID와 본문은 유지됩니다. 대시보드의 **웹훅 → 전송 내역**에서 상태와 시도 횟수를 확인할 수 있습니다. 전송이 종료된 이벤트의 결과는 [조회 API](https://sendwich.kr/docs/api/retrieve-and-cancel/)로 확인합니다. ## 설정 변경과 키 교체 수신 URL, 사용 여부, 서명 키는 인증 생성 시점의 설정을 따릅니다. 변경 후에도 기존 인증의 이벤트와 재전송에는 이전 설정이 사용됩니다. 키를 교체할 때는 기존 인증이 종료된 후 최대 24시간까지 이전 키로도 서명을 검증할 수 있어야 합니다. --- # 직접 만든 인증 화면 > 사용자 화면과 서비스 서버의 구성 예제 문서: https://sendwich.kr/docs/examples/custom-ui/ ## 구성 화면은 인증 안내와 사용자 입력을 담당하고, 서버는 sendwich API 호출과 결과 처리를 담당합니다. | 단계 | 서비스 서버 | 화면 | | --- | --- | --- | | 시작 | 로그인한 사용자의 인증 시도 생성 | 인증 시작 버튼 | | 생성 | API 호출, 사용자와 인증 ID 연결 저장 | 받는 번호·인증 문자·남은 시간 표시 | | 대기 | 웹훅 수신 또는 결과 조회 | 전송 안내, 확인·취소 버튼 | | 완료 | 결과 검증, 서비스 작업 처리 | 완료 화면 | ## 서버 경로 서비스 서버에 구현할 경로의 예시입니다. | 경로 | 처리 | | --- | --- | | `POST /api/phone-verifications` | 사용자 인증·요청 제한, 시도 ID·state 생성, sendwich 생성 API 호출 | | `GET /api/phone-verifications/{attempt}` | 사용자 소유 확인, 저장된 상태 반환 또는 sendwich 조회 | | `POST /api/phone-verifications/{attempt}/cancel` | 사용자 소유 확인 후 취소 API 호출 | | `POST /webhooks/sendwich` | 원본 본문으로 서명 검증, 연결된 시도 갱신 | 조회·취소 요청마다 시도의 소유자를 확인하고, 브라우저에는 화면에 필요한 값만 반환합니다. 쿠키 기반 로그인에서는 CSRF 방어도 적용합니다. ## 화면 데이터 ```javascript // 내 서버가 저장하고 전달한 생성 응답의 일부예요. const instructions = { attempt: localAttemptId, destination: session.destination, sms_text: session.sms_text, expires_at: session.expires_at, server_time: session.server_time }; ``` 받는 번호와 인증 문자를 선택·복사할 수 있도록 표시합니다. 사용자가 직접 문자를 전송하는 절차와 통신사 요금제에 따른 발송 요금을 안내합니다. 문자 앱 연결을 지원하지 않는 기기에서도 복사 기능으로 전송할 수 있어야 합니다. ## 상태 처리 `verified`, `expired`, `cancelled`를 받거나 만료 시각이 지나면 주기적인 조회를 중단합니다. 통신 오류는 결과 미확인 상태로 표시하고 다시 조회할 수 있도록 합니다. 새 인증 시도에는 새 `Idempotency-Key`를 사용합니다. 웹훅으로 데이터베이스를 갱신하면 화면은 서비스 서버에 저장된 상태를 조회할 수 있습니다. sendwich API를 직접 조회할 때마다 [사용량](https://sendwich.kr/docs/usage/)에 반영됩니다. 서버 호출은 [Node.js·Python 클라이언트](https://sendwich.kr/docs/examples/node-python/), 이벤트 수신은 [웹훅 서명 검증 예제](https://sendwich.kr/docs/webhooks/)를 참고합니다. --- # Discord 봇 > 비공개 인증 안내와 역할 부여를 구현하는 Node.js 봇 문서: https://sendwich.kr/docs/examples/discord/ ## 동작 방식 `/인증` 명령을 실행하면 봇이 받는 번호와 인증 문자를 비공개 메시지로 표시합니다. 사용자가 휴대폰에서 문자를 보낸 뒤 **인증 확인**을 누르면 결과를 조회하고 역할을 부여합니다. 이 예제는 Discord Gateway를 사용하므로 별도의 웹훅 수신 서버 없이 실행됩니다. 결과 확인 버튼을 누를 때마다 조회 1회가 API 사용량에 반영됩니다. ## 준비 - Node.js 22.12 이상 - Discord 애플리케이션과 봇 - sendwich 프로젝트 API 키 Discord 서버에 봇을 초대할 때 `applications.commands`, `bot` 범위와 역할 관리 권한을 부여합니다. 봇의 역할은 부여할 역할보다 상위에 배치해야 합니다. ## 설치와 실행 새 디렉터리에서 discord.js를 설치하고 예제 파일을 내려받습니다. ```bash npm install discord.js@14 curl -O https://sendwich.kr/docs/downloads/sendwich-client.mjs curl -O https://sendwich.kr/docs/downloads/discord-bot.mjs ``` `.env`에 서버 환경변수를 설정합니다. ```dotenv SENDWICH_API_KEY=YOUR_LIVE_SECRET_KEY DISCORD_TOKEN=YOUR_BOT_TOKEN DISCORD_GUILD_ID=YOUR_SERVER_ID DISCORD_ROLE_ID=YOUR_VERIFIED_ROLE_ID ``` ```bash node --env-file=.env discord-bot.mjs ``` 지정한 Discord 서버에 `/인증` 명령이 등록됩니다. 명령을 실행하고 안내된 번호로 인증 문자를 전송합니다. ## 예제 코드 [봇 코드 다운로드](https://sendwich.kr/docs/downloads/discord-bot.mjs) · [sendwich 클라이언트 다운로드](https://sendwich.kr/docs/downloads/sendwich-client.mjs) ```javascript // npm install discord.js@14 // Demo state is in memory. Replace attempts/locks with durable application state. import { randomUUID } from 'node:crypto'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, Events, GatewayIntentBits, MessageFlags, SlashCommandBuilder } from 'discord.js'; import { createVerification, getVerification } from './sendwich-client.mjs'; const { DISCORD_TOKEN, DISCORD_GUILD_ID, DISCORD_ROLE_ID, SENDWICH_API_KEY } = process.env; if (!DISCORD_TOKEN || !DISCORD_GUILD_ID || !DISCORD_ROLE_ID || !SENDWICH_API_KEY?.startsWith('veri_live_')) { throw new Error('Set the four server environment variables from the documentation.'); } const client = new Client({ intents: [GatewayIntentBits.Guilds] }); const attempts = new Map(); const locks = new Set(); client.once(Events.ClientReady, async ready => { try { // Creates/updates this command only; does not replace unrelated commands. await ready.application.commands.create( new SlashCommandBuilder().setName('인증').setDescription('휴대폰 번호 인증을 시작해요.').toJSON(), DISCORD_GUILD_ID ); console.log('sendwich verification command ready.'); } catch { console.error('Could not register the command. Check Discord permissions.'); } }); function instructions(attempt) { const session = attempt.session; return { content: `본인 휴대폰에서 아래 문자를 보내 주세요.\n받는 번호: ${session.destination}\n인증 문자: ${session.sms_text}\n만료: \n코드를 다른 사람에게 공유하지 마세요. 문자 요금이 발생할 수 있어요.`, components: [new ActionRowBuilder().addComponents( new ButtonBuilder().setCustomId('sw:check:' + attempt.reference) .setLabel('인증 확인').setStyle(ButtonStyle.Primary) )], allowedMentions: { parse: [] } }; } async function handle(interaction) { const start = interaction.isChatInputCommand() && interaction.commandName === '인증'; const check = interaction.isButton() && interaction.customId.startsWith('sw:check:'); if ((!start && !check) || interaction.guildId !== DISCORD_GUILD_ID) return; // Acknowledge promptly; the SMS may arrive much later. if (start) await interaction.deferReply({ flags: MessageFlags.Ephemeral }); else await interaction.deferUpdate(); const owner = interaction.guildId + ':' + interaction.user.id; if (locks.has(owner)) { await interaction.followUp({ content: '확인 중이에요. 잠시 기다려 주세요.', flags: MessageFlags.Ephemeral }); return; } locks.add(owner); try { let attempt = attempts.get(owner); if (start) { if (!attempt || Date.now() > attempt.keepUntil) { attempt = { reference: randomUUID(), state: randomUUID(), keepUntil: Date.now() + 20 * 60000, lastCheck: 0 }; attempts.set(owner, attempt); } if (!attempt.session) { // A retry after a timeout retains this request and its idempotency key. attempt.session = await createVerification({ mode: 'discover', client_reference: attempt.reference, state: attempt.state }, attempt.reference); } if (Date.parse(attempt.session.expires_at) <= Date.now()) { attempts.delete(owner); await interaction.editReply({ content: '이전 인증이 만료됐어요. /인증으로 다시 시작해 주세요.', components: [] }); } else await interaction.editReply(instructions(attempt)); return; } if (!attempt?.session || interaction.customId !== 'sw:check:' + attempt.reference) { await interaction.editReply({ content: '이 시도를 찾을 수 없어요. /인증으로 다시 시작해 주세요.', components: [] }); return; } if (Date.now() - attempt.lastCheck < 5000) return; attempt.lastCheck = Date.now(); const result = await getVerification(attempt.session.id); if (result.id !== attempt.session.id || result.environment !== 'live' || result.mode !== 'discover' || result.client_reference !== attempt.reference || result.state !== attempt.state) throw new Error('Unexpected result.'); if (result.status === 'pending') { await interaction.editReply({ ...instructions(attempt), content: instructions(attempt).content + '\n아직 확인되지 않았어요. 전송 후 잠시 뒤 확인해 주세요.' }); return; } if (result.status === 'verified' && result.phone) { const member = await interaction.guild.members.fetch(interaction.user.id); // Adding this one role is idempotent. Do not post the full phone in chat. await member.roles.add(DISCORD_ROLE_ID, 'sendwich phone verification'); await interaction.editReply({ content: '인증됐어요. 역할을 부여했어요.', components: [] }); } else if (['expired', 'cancelled'].includes(result.status)) { await interaction.editReply({ content: '인증이 만료되거나 취소됐어요. /인증으로 다시 시작해 주세요.', components: [] }); } else throw new Error('Unexpected result.'); attempts.delete(owner); } catch (error) { const message = error.code === 'monthly_call_limit' ? '서비스의 월 호출 한도에 도달했어요. 운영자에게 문의해 주세요.' : '처리하지 못했어요. 잠시 뒤 다시 확인해 주세요. 시작 중이었다면 /인증으로 재시도해 주세요.'; await interaction.followUp({ content: message, flags: MessageFlags.Ephemeral }); } finally { locks.delete(owner); } } client.on(Events.InteractionCreate, interaction => { void handle(interaction).catch(() => console.error('Discord response unavailable.')); }); setInterval(() => { for (const [owner, attempt] of attempts) if (attempt.keepUntil < Date.now() && !locks.has(owner)) attempts.delete(owner); }, 60000).unref(); await client.login(DISCORD_TOKEN); ``` ## 운영 환경 적용 예제는 인증 시도를 메모리에 저장하므로 봇을 재시작하면 진행 중인 시도가 사라집니다. 여러 프로세스에서 운영하거나 재시작 후 이어서 처리하려면 사용자·서버·인증 ID·state·처리 상태를 데이터베이스에 저장합니다. 사용자별 요청 제한을 적용하고, 인증 ID당 역할 부여를 한 번만 처리합니다. 한 전화번호의 다중 계정 사용 여부는 서비스 정책에 따라 정합니다. 특정 번호를 확인하려면 [match 모드](https://sendwich.kr/docs/verification-modes/)를 사용합니다. ## 역할 자동 부여 확인 버튼 없이 역할을 부여하려면 봇 서버에 [sendwich 웹훅](https://sendwich.kr/docs/webhooks/)을 수신하는 HTTPS 경로를 추가합니다. 수신 서버에서 서명과 인증 시도를 검증한 뒤 Discord API로 역할을 부여합니다. Discord 채널의 웹훅은 sendwich 이벤트를 처리할 수 없으므로 중간 수신 서버가 필요합니다. ## 참고 문서 - [Discord 상호작용](https://docs.discord.com/developers/interactions/receiving-and-responding) - [discord.js 역할 관리](https://discord.js.org/docs/packages/discord.js/14.26.2/GuildMemberRoleManager:Class) --- # Node.js와 Python > Node.js·Python 서버에서 인증을 생성하고 조회하는 예제 문서: https://sendwich.kr/docs/examples/node-python/ ## Node.js Node.js 22.12 이상에서 외부 패키지 없이 실행합니다. [sendwich-client.mjs 다운로드](https://sendwich.kr/docs/downloads/sendwich-client.mjs) ```javascript // Node.js 22.12+. Server-side only. No dependencies. const API = 'https://api.sendwich.kr'; export class SendwichError extends Error { constructor(status, code, requestId) { super(`sendwich: ${code} (${status})`); this.status = status; this.code = code; this.requestId = requestId; } } async function request(path, method = 'GET', body, idempotencyKey) { const key = process.env.SENDWICH_API_KEY; if (!key) throw new Error('Set the server-only SENDWICH_API_KEY variable.'); const response = await fetch(API + path, { method, signal: AbortSignal.timeout(10000), headers: { Authorization: `Bearer ${key}`, ...(body ? { 'Content-Type': 'application/json' } : {}), ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) }); const data = await response.json().catch(() => ({})); if (!response.ok) throw new SendwichError( response.status, data.error?.code || 'request_failed', data.error?.request_id || response.headers.get('X-Request-ID') ); return data; } function sessionPath(id) { if (!/^vrf_[A-Za-z0-9_-]+$/.test(id)) throw new Error('Invalid verification ID.'); return `/v1/verifications/${encodeURIComponent(id)}`; } export function createVerification(body, idempotencyKey) { if (!/^[A-Za-z0-9_.:-]{8,128}$/.test(idempotencyKey || '')) { throw new Error('Provide a stable Idempotency-Key for this attempt.'); } // Retry uncertain creates using the same body and key; do not auto-generate here. return request('/v1/verifications', 'POST', body, idempotencyKey); } export const getVerification = id => request(sessionPath(id)); export const cancelVerification = id => request(sessionPath(id) + '/cancel', 'POST'); ``` 같은 디렉터리에 `start.mjs`를 작성합니다. ```javascript import { randomUUID } from 'node:crypto'; import { createVerification, getVerification } from './sendwich-client.mjs'; const attempt = randomUUID(); const request = { mode: 'discover', client_reference: attempt, state: randomUUID() }; const session = await createVerification(request, attempt); // 본인의 비공개 터미널에서 확인하는 실행 예제예요. console.log('인증 ID:', session.id); console.log('받는 번호:', session.destination); console.log('보낼 문자:', session.sms_text); console.log('만료:', session.expires_at); // 이후 문자를 보낸 뒤 getVerification(session.id)로 조회해요. ``` ```bash SENDWICH_API_KEY='YOUR_LIVE_SECRET_KEY' node start.mjs ``` ## Python Python 3.10 이상에서 표준 라이브러리로 실행합니다. [sendwich_client.py 다운로드](https://sendwich.kr/docs/downloads/sendwich_client.py) ```python """Python 3.10+. Standard library only; use on your server.""" import json import os import re from urllib.error import HTTPError from urllib.request import Request, urlopen API = "https://api.sendwich.kr" class SendwichError(Exception): def __init__(self, status, code, request_id): super().__init__(f"sendwich: {code} ({status})") self.status, self.code, self.request_id = status, code, request_id def _request(path, method="GET", body=None, idempotency_key=None): key = os.environ["SENDWICH_API_KEY"] headers = {"Authorization": f"Bearer {key}"} if body is not None: headers["Content-Type"] = "application/json" if idempotency_key: headers["Idempotency-Key"] = idempotency_key data = json.dumps(body).encode("utf-8") if body is not None else None try: with urlopen(Request(API + path, data=data, headers=headers, method=method), timeout=10) as response: return json.load(response) except HTTPError as error: with error: try: detail = json.load(error).get("error", {}) except (ValueError, AttributeError): detail = {} raise SendwichError(error.code, detail.get("code", "request_failed"), detail.get("request_id") or error.headers.get("X-Request-ID")) from None def create_verification(body, idempotency_key): if not re.fullmatch(r"[A-Za-z0-9_.:-]{8,128}", idempotency_key): raise ValueError("Provide a stable Idempotency-Key for this attempt") return _request("/v1/verifications", "POST", body, idempotency_key) def _session_path(verification_id): if not re.fullmatch(r"vrf_[A-Za-z0-9_-]+", verification_id): raise ValueError("Invalid verification ID") return "/v1/verifications/" + verification_id def get_verification(verification_id): return _request(_session_path(verification_id)) def cancel_verification(verification_id): return _request(_session_path(verification_id) + "/cancel", "POST") ``` 같은 디렉터리에서 다음과 같이 사용합니다. 실행 환경에 `SENDWICH_API_KEY`를 설정해야 합니다. ```python import uuid from sendwich_client import create_verification, get_verification attempt = str(uuid.uuid4()) session = create_verification( {"mode": "discover", "client_reference": attempt, "state": str(uuid.uuid4())}, attempt, ) # 본인의 비공개 터미널에서만 코드를 확인해요. print(session["id"], session["destination"], session["sms_text"], session["expires_at"]) # 문자를 보낸 뒤 get_verification(session["id"])로 조회해요. ``` ## 서비스에 적용 인증 ID, 요청 본문, `Idempotency-Key`를 사용자·시도와 함께 저장합니다. 생성 요청을 재시도할 때는 같은 키와 본문을 사용하고, 오류의 `code`와 `status`에 따라 [재시도 조건](https://sendwich.kr/docs/api/errors/)을 적용합니다. 문자 전송 후 `getVerification` 또는 `get_verification`으로 조회합니다. 결과 반영 시 확인할 필드는 [상태와 결과](https://sendwich.kr/docs/api/retrieve-and-cancel/)에 정리되어 있습니다. 예제의 콘솔 출력은 로컬 실행용입니다. 운영 환경에서는 인증 안내를 해당 사용자에게 전달합니다. --- # 요금제와 사용량 > 요금제별 기능, API 호출 집계, 월 사용량 한도 문서: https://sendwich.kr/docs/usage/ ## 요금제별 기능 | 항목 | Free | Plus | | --- | --- | --- | | API 생성·조회·취소 | 제공 | 제공 | | 웹훅 | 제공 | 제공 | | 직접 만든 화면·봇 | 제공 | 제공 | | 인증 코드 | 한글 4글자 | 한글·영문·숫자 | | 유효시간 | 60~900초 | 60~900초 | | 기본 인증 창·QR 설정 | 제공 | 제공 | | 인증 창 템플릿·브랜딩·워터마크 제거 | 기본 화면 | 제공 | 월 API 호출 한도, 프로젝트 생성 한도, 가격은 [요금제](https://sendwich.kr/pricing/)와 [대시보드 결제 관리](https://sendwich.kr/dashboard/#billing)에서 확인할 수 있습니다. 프로그램에서 조회할 때는 `GET https://api.sendwich.kr/public/v1/plans`를 사용합니다. ## 호출 집계 월 사용량은 계정에 속한 프로젝트가 공유하며, API 요청 단위로 집계합니다. | 요청·동작 | 집계 | | --- | --- | | 인증 생성·조회·취소 | 요청마다 1회 | | 유효한 프로젝트 키로 발생한 오류·재시도 | 요청마다 집계 | | 같은 Idempotency-Key로 재시도 | 집계 | | 대시보드 인증 체험 생성 | 집계 | | 일반 대시보드 조회·설정 변경 | 제외 | | sendwich 인증 창의 상태 조회 | 제외 | | 문자 수신·웹훅 전송 | 제외 | 잘못되거나 폐기된 키처럼 계정에 연결할 수 없는 요청은 집계에서 제외합니다. ## 월 사용량 한도 한국 시간(`Asia/Seoul`) 기준 매월 1일 00시에 새 월의 집계를 시작합니다. 한도를 넘으면 `429 monthly_call_limit`을 반환하며 자동 초과 결제는 발생하지 않습니다. 이미 열린 인증 창과 웹훅 처리는 계속될 수 있습니다. | 응답 헤더 | 값 | | --- | --- | | `X-Usage-Calls` | 현재 월의 호출 수 | | `X-Usage-Limit` | 월 한도 또는 `unlimited` | 요금제를 변경해도 해당 월의 사용량은 유지됩니다. ## 조회 횟수 관리 5초 간격으로 5분 동안 조회하면 한 인증에 약 60회의 조회가 발생합니다. 웹훅으로 결과를 저장하거나 사용자가 확인 버튼을 누를 때 조회하면 호출을 줄일 수 있습니다. 주기적인 조회는 최종 상태에 도달하거나 만료 시각이 지나면 중단합니다. --- # 문제 해결 > 문자 인증, 팝업 연결, 웹훅 수신 오류 해결 문서: https://sendwich.kr/docs/troubleshooting/ ## 문자 전송 후 pending 유지 | 항목 | 확인 사항 | | --- | --- | | 수신 번호 | 최신 인증 응답의 `destination`과 일치하는지 | | 문자 본문 | `sms_text`와 일치하며 중간 공백·추가 문장이 없는지 | | 발신 번호 | `match`에서 지정한 번호의 SIM으로 전송했는지 | | 유효시간 | SMS 수신 처리 전에 `expires_at`이 지났는지 | | 조회 대상 | 문자를 전송한 인증의 ID로 조회했는지 | 대시보드의 **인증 체험**에서 새 시도를 진행해 수신 상태를 확인할 수 있습니다. 문제가 계속되면 [문의](https://sendwich.kr/dashboard/#support)에 인증 ID, 발생 시각, 요청 ID를 전달합니다. ## API 연동 중 URL 검증 오류 API 직접 연동에서는 생성 요청의 `parent_origin`, `return_url`을 생략합니다. 두 필드를 사용한다면 프로젝트에 등록한 주소와 일치해야 합니다. 웹훅 수신 URL은 별도 설정입니다. 웹훅을 사용하는 경우에만 서비스 서버의 HTTPS 주소를 등록합니다. ## 팝업 차단 클릭 이벤트에서 빈 팝업을 열고, 비동기 서버 요청이 끝나면 해당 창의 주소를 변경합니다. 팝업을 열 수 없으면 같은 탭에서 이동하도록 처리합니다. [팝업 예제](https://sendwich.kr/docs/verification-window/)를 참고합니다. ## 문자 앱의 본문 자동 입력 실패 화면에 표시된 받는 번호와 인증 문자를 복사해 전송합니다. 직접 만든 화면에도 번호와 문자 복사 기능을 제공합니다. ## 인증 완료 후 원래 화면에 미반영 생성 요청에 `parent_origin`이 포함되어 있고 프로젝트 등록값과 일치하는지 확인합니다. `postMessage`의 수신 출처는 `https://id.it.kr`입니다. 팝업 알림이나 복귀 URL을 받은 뒤 서버에서 결과를 조회해야 합니다. 팝업 연결이 끊기거나 알림이 유실된 경우에도 API 조회·웹훅으로 최종 상태를 확인할 수 있습니다. ## 웹훅 미수신 웹훅을 저장한 후 생성한 인증인지 확인합니다. 대시보드의 전송 내역에서 HTTP 상태와 시도 횟수를 확인할 수 있습니다. 수신 주소는 외부에서 접근 가능한 HTTPS 443 포트여야 합니다. `301`, `302` 리디렉션은 지원하지 않습니다. ## 웹훅 서명 불일치 JSON 파싱 전의 원본 본문 바이트, 서버 시각, `whsec_` 접두사를 포함한 서명 키를 확인합니다. 키를 교체한 경우 기존 인증의 이벤트에는 이전 키를 사용해야 합니다. [서명 검증](https://sendwich.kr/docs/webhooks/)을 참고합니다. ## 예상보다 많은 호출 사용량 조회·실패·재시도도 각각 API 호출로 집계됩니다. 주기적인 조회 간격과 종료 조건을 확인합니다. `429 monthly_call_limit`은 월 허용량을 소진한 상태이므로 다음 집계 월이나 요금제 변경 후 재개합니다. 자세한 집계 기준은 [요금제와 사용량](https://sendwich.kr/docs/usage/)에서 확인할 수 있습니다. ## 기존 테스트 프로젝트 기존 테스트 프로젝트와 결과는 `test`로 표시되며 시뮬레이션으로 동작합니다. 실제 번호 확인에는 새 SMS 프로젝트의 `live` 결과를 사용합니다. 공개 데모는 화면 체험용입니다. --- # LLM용 문서 > 코딩 도구용 Markdown 문서와 OpenAPI 명세 문서: https://sendwich.kr/docs/llms/ ## 문서 주소 | 자료 | 주소 | 용도 | | --- | --- | --- | | 문서 색인 | [llms.txt](https://sendwich.kr/llms.txt) | 주제별 문서 탐색 | | 전체 문서 | [llms-full.txt](https://sendwich.kr/llms-full.txt) | 전체 내용을 한 파일로 전달 | | 페이지별 Markdown | 각 문서 상단의 **Markdown** | 필요한 페이지 전달 | | OpenAPI 명세 | [openapi.json](https://sendwich.kr/docs/openapi.json) | 인증 생성·조회·취소의 요청·응답 스키마 | 각 페이지의 **페이지 복사**로 Markdown을 복사하거나, 코딩 도구에 문서 주소를 전달합니다. ## 요청 예시 ```text sendwich로 휴대폰 번호 인증을 연결해 줘. 먼저 https://sendwich.kr/llms.txt를 읽고 필요한 문서를 확인해. API 주소는 https://api.sendwich.kr, 제공되는 인증 창 주소는 https://id.it.kr야. 이번에는 Discord 봇에서 API로 직접 연동할 거야. API 키는 서버의 SENDWICH_API_KEY 환경변수를 사용하고, 사용자에게 받은 destination과 sms_text를 비공개로 보여 줘. parent_origin과 return_url은 생략해. 인증 ID와 Discord 사용자·시도를 서버에서 연결해 저장하고, 조회 결과 또는 검증된 웹훅으로 성공을 확인해. Free 요금제에서도 동작하도록 만들어 줘. ```