# simply-notifier — 코딩 AI 통합 가이드 (llms.txt) > 이 문서는 **호출 앱을 작성하는 코딩 AI**가 simply-notifier 서버에 연동 코드를 바로 작성할 수 있도록 만든 단일 파일 명세입니다. > 사람이 읽는 전체 문서는 저장소의 `README.md`, 대화형 API 문서는 `/docs`(Swagger)에 있습니다. ## 이 서버가 하는 일 휴대폰 **인증번호(OTP)** 와 **개인화 알림(대량 SMS/카카오 알림톡)** 을 발송하는 공용 서버입니다. 호출 앱은 HMAC 서명으로 인증한 뒤 두 가지를 호출합니다. 1. **OTP**: 휴대폰번호로 일회용 코드 발송. 성공 판정은 호출 앱이 직접 하거나 서버 `/check`로 검증. 2. **개인화 알림**: 서버에 미리 등록된 **템플릿**에 수신자별 `{변수}`만 채워 대량 발송. 본문은 호출 앱이 바꿀 수 없음(보안). 발송 백엔드는 aligo / solapi 둘 다 지원하고 관리 웹에서 런타임 전환합니다. 호출 앱은 백엔드를 신경 쓸 필요 없습니다. ## 연동에 필요한 값 (사람에게 받아야 함) | 값 | 설명 | 예시 | |---|---|---| | `base_url` | 서버 주소 | `https://notify.example.com` 또는 `http://52.78.166.26:8080` | | `app_id` | 관리 웹에서 발급한 앱 ID | `app_xxxxxxxxxxxx` | | `api_secret` | 앱 시크릿(HMAC 키). **서버 환경변수/시크릿 저장소에만 보관** | `smsk_...` | > 아직 없으면 관리자에게 관리 웹(`{base_url}/admin`)에서 앱 등록 + 템플릿 등록을 요청하세요. ## 절대 규칙 (먼저 읽기) 1. **`api_secret`은 서버에서만 사용.** 모바일 앱/브라우저/프론트엔드 번들에 절대 넣지 마세요. 유출 시 충전금이 소진될 수 있습니다. 2. **알림 본문은 서버 템플릿 고정.** 호출 앱은 `{변수}` **값만** 채웁니다. 임의 본문 발송 불가. 3. **서명 대상 바디와 실제 전송 바디는 바이트 단위로 동일**해야 합니다. JSON을 만들고 그 바이트로 SHA256을 구한 뒤, **재직렬화하지 말고 같은 바이트를 그대로 전송**하세요. (한글은 `ensure_ascii=false`로 그대로 두는 것을 권장) 4. **건당 과금**입니다. 개발/테스트는 항상 `dry_run: true` 또는 mock 프로바이더를 쓰세요. --- ## 인증: HMAC-SHA256 요청 서명 모든 `/v1/*` 호출 앱 요청에 아래 헤더가 필요합니다. | 헤더 | 값 | |---|---| | `X-App-Id` | 발급받은 `app_id` | | `X-Timestamp` | unix 초(문자열). 서버 시각과 **±300초** 이내여야 함 | | `X-Nonce` | 요청마다 고유한 문자열(UUID 권장). 재전송 차단용 | | `X-Signature` | 아래 서명(hex) | | `X-Idempotency-Key` | (선택) 같은 키로 1시간 내 재요청 시 **이전 응답을 그대로 반환**(중복 발송 방지). 재시도 로직에 강력 권장 | | `Content-Type` | 바디가 있으면 `application/json` | ### 서명 만드는 법 ``` BODY = 요청 바디 바이트 (GET이면 빈 바이트 b"") canonical = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + SHA256_HEX(BODY) X-Signature = HMAC_SHA256_HEX(api_secret, canonical) ``` - `METHOD`: 대문자 (`POST`, `GET`) - `PATH`: 쿼리스트링 포함 경로 (예: `/v1/verifications`, `/v1/notifications/abc?x=1`). base_url은 제외. - `SHA256_HEX(BODY)`: 바디 바이트의 SHA256 hex. GET은 빈 바디의 해시. - `canonical`의 구분자는 줄바꿈 `\n` 5개 필드. ### 검증된 클라이언트 (의존성 0) 직접 구현하지 말고 저장소의 레퍼런스 클라이언트를 그대로 쓰는 걸 권장합니다. - Python: `clients/python_client.py` — `SimplyNotifierClient(base_url, app_id, api_secret)` - Node.js: `clients/node_client.js` ### Python 최소 구현 (표준 라이브러리만) ```python import hashlib, hmac, json, time, uuid, urllib.request, urllib.error def call(base_url, app_id, api_secret, method, path, payload=None, idempotency_key=None): method = method.upper() body = b"" if method == "GET" else json.dumps(payload or {}, ensure_ascii=False).encode("utf-8") ts, nonce = str(int(time.time())), uuid.uuid4().hex canonical = "\n".join([method, path, ts, nonce, hashlib.sha256(body).hexdigest()]) sig = hmac.new(api_secret.encode(), canonical.encode(), hashlib.sha256).hexdigest() headers = {"X-App-Id": app_id, "X-Timestamp": ts, "X-Nonce": nonce, "X-Signature": sig} if body: headers["Content-Type"] = "application/json" if idempotency_key: headers["X-Idempotency-Key"] = idempotency_key req = urllib.request.Request(base_url.rstrip("/") + path, data=body or None, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=15) as r: return json.loads(r.read().decode()) except urllib.error.HTTPError as e: raise RuntimeError(f"{e.code} {e.read().decode()}") ``` --- ## 엔드포인트 오류 응답은 항상 `{"error": {"code": "...", "message": "...", ...}}` 형식입니다. ### POST `/v1/verifications` — 인증번호(OTP) 발송 요청: ```json { "phone": "010-1234-5678", "user_id": "user-42", "session_id": "sess-abc", "dry_run": false, "return_code": true } ``` - `phone`(필수), `user_id`(필수, 호출 앱 내부 식별자), `session_id`(선택, 한도 적용). - `return_code: false`면 응답에 `code`를 안 줌 → 서버측 `/check`로 검증할 때 사용. 응답 200: ```json { "request_id": "f3a8...", "code": "483920", "phone": "010****5678", "expires_in": 300, "expires_at": "...", "provider": "aligo", "provider_message_id": "..." } ``` 호출 앱은 `code`를 보관했다가 사용자 입력과 비교하거나, 아래 `/check`를 쓴다. ### POST `/v1/verifications/check` — 서버측 코드 검증(선택) 요청: `{ "request_id": "f3a8...", "code": "483920" }` 응답 200: `{ "status": "verified", "user_id": "...", ... }` (실패 시 오류 응답) ### POST `/v1/notifications` — 개인화 알림 발송(대량) 미리 등록한 템플릿(`template`=이름 또는 `template_id`)에 수신자별 변수를 채워 발송. 요청: ```json { "template": "투표안내", "dry_run": false, "recipients": [ { "phone": "010-1111-2222", "ref": "voter-1", "variables": { "name": "홍길동", "code": "A1B2", "url": "https://vote.kr/1" } }, { "phone": "010-3333-4444", "ref": "voter-2", "variables": { "name": "김철수", "code": "C3D4", "url": "https://vote.kr/2" } } ] } ``` - `template` 또는 `template_id` **둘 중 하나 필수**. - `recipients`: 1~10000명. 각 항목 `phone`(필수), `variables`(템플릿 자리표시자에 채울 값), `ref`(선택, 결과에 그대로 echo됨). - `dry_run: true`면 실제 발송 없이 렌더 결과만 미리보기(한도 미차감). **테스트는 항상 이걸로.** 응답 200: ```json { "batch_id": "94e4...", "template_id": "tpl_...", "channel": "sms", "requested": 2, "sent": 2, "failed": 0, "skipped": 0, "dry_run": false, "results": [ { "ref": "voter-1", "phone": "010****2222", "status": "sent", "channel": "sms", "provider": "aligo", "message_id": "..." } ] } ``` 수신자별 `status`: - `sent` 발송 / `failed` 발송실패 / `error` 변수 누락·값 오류 / `skipped` 차단·번호한도·잘못된번호 / `preview` dry-run. **부분 실패 규칙**: 한 수신자의 변수 오류·잘못된 번호는 그 사람만 건너뛰고 배치는 계속됩니다. 단, **수신번호 중복**과 **앱/전역 일일 한도 초과**는 배치 전체를 거부합니다(아무도 안 보냄). ### POST `/v1/notifications/upload` — CSV 본문으로 발송 `{ "template": "투표안내", "csv_content": "phone,ref,name,code\n010-1111-2222,v1,홍길동,A1B2\n", "dry_run": false }` CSV 헤더: `phone`(필수), `ref`(선택), 나머지 열 = 템플릿 변수. ### GET `/v1/notifications/{batch_id}` — 배치 결과 재조회 발송 응답의 `batch_id`로 나중에 결과를 다시 조회. (GET도 서명 필요) --- ## 템플릿 규칙 (알림 본문) - 본문은 관리 웹/CLI/관리자 API로 **서버에 미리 등록**. 호출 앱은 변수 **값만** 채움. - 자리표시자는 `{이름}` 형태의 **단순 변수만** 허용. `{x.attr}` 등 금지. - `{app_name}`은 서버가 자동 주입(호출 앱이 채울 필요 없음). - 변수 값은 길이·제어문자 제한으로 정제됨. - **SMS**: 본문만 등록. 90바이트 초과 시 LMS 자동 전환. - **카카오 알림톡**: 카카오 **사전 승인 템플릿 코드** + 발신프로필 필요. 실패 시 SMS 대체(`sms_fallback`) 가능. - 호출 앱이 보내는 `variables` 키는 **그 템플릿이 요구하는 변수와 정확히 일치**해야 함. 어떤 변수가 필요한지는 관리자/관리 웹에서 확인. --- ## 오류 코드 | HTTP | code | 의미 / 대응 | |---|---|---| | 401 | `auth_*` (서명/타임스탬프/nonce 오류) | 서명 로직·시계 동기화 확인 (±300초) | | 403 | `template_inactive` / `auth_app_inactive` | 비활성 템플릿/앱 — 관리자 문의 | | 404 | `template_not_found` | 템플릿 이름/ID 오타 | | 409 | `template_exists` | (관리) 템플릿 이름 중복 | | 422 | `too_many_recipients` / `duplicate_recipients` / `invalid_template` / `kakao_template_code_required` | 수신자 초과 / 번호 중복 / 형식 오류 / 카카오 코드 누락 | | 429 | `rate_limited.app_daily` `.global_daily` 등 | 한도 초과. 응답에 `retry_after`(초)와 `Retry-After` 헤더 포함 → 그만큼 대기 후 재시도 | 발송 **실패 시 한도는 차감되지 않습니다**. 429를 만나면 재시도는 `retry_after` 이후에, 그리고 `X-Idempotency-Key`를 붙여 중복 발송을 막으세요. --- ## 따라하기: 호출 앱 연동 순서 1. 관리자에게 `base_url`, `app_id`, `api_secret`, 사용할 **템플릿 이름**과 **필요한 변수 목록**을 받는다. 2. `api_secret`을 서버 환경변수/시크릿 저장소에 저장(코드/프론트엔드에 넣지 않음). 3. 레퍼런스 클라이언트(`python_client.py`/`node_client.js`)를 가져오거나 위 최소 구현을 사용한다. 4. **`dry_run: true`로 먼저 호출**해 렌더 결과를 확인한다. 5. 결과 OK면 `dry_run: false`로 발송. 재시도 경로에는 `X-Idempotency-Key`를 항상 붙인다. 6. 응답의 `results[].status`(또는 `code`)를 확인해 부분 실패를 처리한다. ## AI가 흔히 하는 실수 (피하기) - ❌ 바디를 서명용으로 한 번, 전송용으로 또 직렬화 → 바이트 불일치로 401. **같은 바이트를 재사용**. - ❌ `PATH`에 base_url을 포함하거나 쿼리스트링 누락 → 서명 불일치. - ❌ secret을 클라이언트(앱/브라우저)에 임베드. - ❌ 테스트인데 `dry_run`을 빼서 실제 과금/발송. - ❌ 알림 본문을 요청에 직접 넣으려 함 → 불가. 템플릿+변수만. - ❌ 매 재시도마다 새 요청으로 보내 중복 발송 → `X-Idempotency-Key` 사용. --- simply-notifier v2.0.0 · 전체 문서: README.md · Swagger: /docs · 관리 웹: /admin