# PINCOBIZ (핑코비즈) Open API v1 & Remote MCP — AI 원샷 연동 마스터 규격서 (llms-full.txt) 이 문서는 AI 코딩 에이전트 및 개발자가 별도의 문서 탐색 없이 **단 한 번에 핑코비즈(PINCOBIZ) 문자(SMS·LMS·MMS) 발송 시스템을 완벽히 구현**할 수 있도록 작성된 공식 전체 규격서입니다. --- ## 1. 기본 접속 및 인증 규격 (Authentication & Security) - **REST API Base URL**: `https://sms-api.pinco.kr` - **인코딩**: 요청·응답 JSON은 `UTF-8`이며, 통신사 규격상 **메시지 본문(`body`) 길이 제한만 `EUC-KR` 바이트 수**로 계산합니다. - **인증 헤더** (`GET /api/open/v1/health`를 제외한 모든 엔드포인트 필수, 택 1): 1. `X-Pinco-Api-Token: pco_...` *(권장)* 2. `Authorization: Bearer pco_...` - **토큰 발급 및 IP 화이트리스트**: - 웹 콘솔(`https://sms.pinco.kr/console/openapi`)에서 발급 (원문은 발급 직후 1회만 노출, 서버에는 SHA-256 해시만 저장) - 토큰당 최대 **15개**의 허용 서버 공인 IP 또는 CIDR 대역(예: `211.45.12.34`, `203.0.113.0/24`) 지정 가능 - 코드 작성 시 토큰을 절대 소스코드에 하드코딩하지 말고 환경변수(`PINCO_API_TOKEN`)에서 주입받도록 구현하세요. --- ## 2. 공통 응답 Envelope 규격 (Common Response Format) 모든 Open API 엔드포인트(`200 OK` 및 `4xx`/`5xx` 에러 포함)는 동일한 최상위 JSON 구조를 반환합니다. ### 성공 응답 (`status: true`) ```json { "status": true, "message": "정상 처리되었습니다.", "data": { ... }, "timestamp": 1793716522000 } ``` - `status` (`boolean`, 필수): 요청 성공 여부 (`true` / `false`) - `message` (`string`, 필수): 한국어 결과 안내 메시지 - `data` (`object`, 선택): 엔드포인트별 결과 데이터 (`GET /health` 등 데이터 없는 응답에서는 생략 가능) - `timestamp` (`number`, 필수): 응답 생성 시각 (Unix Epoch 밀리초) ### 실패 응답 (`status: false`) ```json { "status": false, "error": "INVALID_REQUEST", "message": "SMS 본문은 EUC-KR 기준 최대 90바이트까지 가능합니다. (현재 112바이트 — LMS로 전환해 주세요)", "timestamp": 1793716522000 } ``` - HTTP 상태 코드가 `200`이라도 방어적 코딩을 위해 반드시 **`res.ok && body.status === true`** 두 조건을 모두 확인하세요. --- ## 3. 분당 호출 제한 (Rate Limit) 및 재시도 설계 1분 고정 윈도우(Fixed Window) 기준으로 아래 호출 한도가 적용되며, 모든 응답에 상태 헤더가 포함됩니다. | 구분 | 대상 엔드포인트 | 기준 단위 | 분당 허용 한도 | | :--- | :--- | :---: | :---: | | **문자 발송 접수** | `POST /api/open/v1/messages/send` | API 토큰당 | **300회 / 분** | | **상태·잔액·발신번호·캠페인 조회** | `GET /products/status`, `/balance`, `/sender-numbers`, `/campaigns/{id}` | API 토큰당 | **30회 / 분** | | **사전 검증 · 예약 취소 · MCP** | `POST /messages/preview`, `POST /campaigns/{id}/cancel`, `/mcp` | API 토큰당 | **30회 / 분** | | **MMS 이미지 업로드** | `POST /api/open/v1/mms/images` | API 토큰당 | **10회 / 분** | | **서버 헬스체크** | `GET /api/open/v1/health` | 공인 IP당 | **10회 / 분** | ### 응답 헤더 - `X-RateLimit-Limit`: 해당 엔드포인트의 1분 최대 허용 횟수 - `X-RateLimit-Remaining`: 현재 윈도우 내 남은 호출 가능 횟수 - `X-RateLimit-Reset`: 윈도우 초기화 시각 (Unix Epoch **초**) - `Retry-After`: `HTTP 429`(`error: "RATE_LIMITED"`) 발생 시 다음 호출까지 대기해야 하는 **초(seconds)** > **대량 동보 발송 베스트 프랙티스**: 1,000명에게 같은 템플릿(`{이름}` 치환 포함) 문자를 보낼 때 1,000번 API를 루프 호출하지 말고, `recipients` 배열에 묶어 **단 1회 호출**로 전송하세요. --- ## 4. 대한민국 통신사 · KISA 발송 정책 핵심 규칙 1. **EUC-KR 바이트 제한**: - `SMS` (단문): **최대 90 bytes** (제목 `subject` 미지원) - `LMS` (장문): **최대 2,000 bytes** (`subject` 선택 지원) - `MMS` (포토): **최대 2,000 bytes** + 사전 업로드된 JPG 이미지 `attachmentFileIds` **1~3장 필수** - `{이름}` 개인화 치환 사용 시, 수신자 중 가장 긴 `recipientName`이 치환된 후의 바이트 길이가 상한을 넘지 않아야 합니다. 2. **발신번호 사전등록제 (전기통신사업법 제84조의2)**: - 웹 콘솔에서 서류/본인인증으로 사전 승인(`status: "APPROVED"`, `sendable: true`)된 `senderNumber`만 발송 가능 (하이픈 제거 숫자 권장, 예: `"15334005"`, `"01012345678"`). 3. **광고성 문자 (`purpose: "AD"`) 의무 규격 (정보통신망법 제50조)**: - **야간 발송 제한**: KST 기준 **`08:00 ~ 20:45`** 사이에만 즉시 발송 또는 예약 시각 설정 가능 (`20:45 ~ 익일 08:00` 시도 시 `400 AD_POLICY_VIOLATION`). - **표기 의무**: 본문 시작에 `(광고)` + 업체명, 본문 하단에 `무료수신거부 080번호`가 반드시 포함되어야 함. - **권장 방법**: `purpose: "AD"`, `companyName: "업체명"`, `autoFormatAd: true`를 전달하면 서버가 표준 포맷과 기본 080 무료수신거부 번호를 자동으로 안전하게 부착합니다. 4. **예약 발송 및 취소**: - 예약 설정(`scheduled: true`): `scheduledAt` (`"YYYY-MM-DD HH:mm"`, KST)은 현재 시각으로부터 **최소 30분 이후**여야 함 (`SCHEDULE_TOO_SOON` 방지). - 예약 취소: 발송 예정 시각 **5분 전 이전**까지만 취소 가능하며 취소 즉시 선차감 예치금 100% 환급 (`CANCEL_DENIED` 주의). --- ## 5. 전체 엔드포인트 상세 명세 (9종) ### 5.1 서버 상태 체크 — `GET /api/open/v1/health` - 인증: 불필요 (`IP당 10회/분`) - 성공 응답(`200`): ```json { "status": true, "message": "PingcoBiz Open API v1 서버가 정상 가동 중입니다.", "timestamp": 1793716522000 } ``` ### 5.2 발송 가능 상품 · 권한 조회 — `GET /api/open/v1/products/status` - 인증: 필수 (`토큰당 30회/분`) - Query Params: `product` (선택, `"SMS"` | `"LMS"` | `"MMS"`) - 성공 응답(`200`): ```json { "status": true, "message": "발송 가능 상품 상태가 조회되었습니다.", "data": { "canSend": true, "canSendApi": true, "adSendableNow": true, "adAllowedWindow": "08:00~20:45", "singleSendLimit": 10000, "dailySendLimit": 50000, "todaySentCount": 120, "remainingDailyQuota": 49880, "products": { "SMS": { "available": true, "unitPrice": 8.40, "maxBytes": 90, "purposes": { "INFO": true, "AD": true, "ELECTION": false }, "reasons": [] }, "LMS": { "available": true, "unitPrice": 25.00, "maxBytes": 2000, "purposes": { "INFO": true, "AD": true, "ELECTION": false }, "reasons": [] }, "MMS": { "available": true, "unitPrice": 60.00, "maxBytes": 2000, "maxAttachments": 3, "purposes": { "INFO": true, "AD": true, "ELECTION": false }, "reasons": [] } } }, "timestamp": 1793716522000 } ``` ### 5.3 잔액 · 단가 조회 — `GET /api/open/v1/balance` - 인증: 필수 (`토큰당 30회/분`) - 성공 응답(`200`): ```json { "status": true, "message": "잔액 및 적용 단가가 조회되었습니다.", "data": { "balance": 154200.00, "unitPrice": { "SMS": 8.40, "LMS": 25.00, "MMS": 60.00 } }, "timestamp": 1793716522000 } ``` ### 5.4 승인 발신번호 목록 조회 — `GET /api/open/v1/sender-numbers` - 인증: 필수 (`토큰당 30회/분`) - Query Params: - `sendableOnly` (`boolean`, 선택): `true` 지정 시 즉시 발송 가능한 번호만 반환 - `type` (`string`, 선택): `"LANDLINE"`(유선·대표번호) | `"MOBILE"`(휴대전화) - 성공 응답(`200`): ```json { "status": true, "message": "등록된 발신번호 목록이 조회되었습니다.", "data": { "count": 1, "senderNumbers": [ { "senderNumber": "15334005", "type": "LANDLINE", "status": "APPROVED", "sendable": true, "expired": false, "expiresAt": "2027-05-01T23:59:59" } ] }, "timestamp": 1793716522000 } ``` ### 5.5 발송 사전 검증 · 비용 시뮬레이션 (Dry-Run) — `POST /api/open/v1/messages/preview` - 인증: 필수 (`토큰당 30회/분`) — 실제 문자 발송 및 예치금 차감 없음 - Request Body (`application/json`): `5.6 문자 발송 접수`와 동일 (`recipients`는 선택 입력 가능) - 성공 응답(`200`): ```json { "status": true, "message": "발송 사전 검증이 완료되었습니다.", "data": { "valid": true, "eucKrByteLength": 42, "recommendedMessageType": "SMS", "formattedBody": "[핑코비즈] 홍길동님 인증번호는 [482910]입니다.", "requestedCount": 1, "excludedUnsubscribeCount": 0, "invalidSkippedCount": 0, "acceptedCount": 1, "appliedUnitPrice": 8.40, "estimatedChargeAmount": 8.40, "currentBalance": 154200.00, "sufficientBalance": true, "warnings": [] }, "timestamp": 1793716522000 } ``` ### 5.6 문자 발송 접수 (즉시 · 예약) — `POST /api/open/v1/messages/send` - 인증: 필수 (`토큰당 300회/분`) - Request Body (`application/json`): - `senderNumber` (`string`, **필수**): 사전등록 승인 완료된 발신번호 (예: `"15334005"`) - `messageType` (`string`, **필수**): `"SMS"` | `"LMS"` | `"MMS"` - `purpose` (`string`, 선택): `"INFO"` (기본값, 정보성) | `"AD"` (광고성) | `"ELECTION"` (선거) - `subject` (`string`, 선택): LMS / MMS 제목 (SMS에서는 생략) - `body` (`string`, **필수**): 메시지 본문 (`{이름}` 입력 시 수신자별 `recipientName`으로 치환) - `companyName` (`string`, 선택): 광고 문자 상단 표기 업체명 (`purpose: "AD"` 시 권장) - `optOutNumber` (`string`, 선택): 080 무료수신거부 번호 (생략 시 기본 제공 080 번호 자동 적용) - `autoFormatAd` (`boolean`, 선택): `true` 설정 시 `(광고)` 및 `무료수신거부 080` 문구 자동 부착 - `scheduled` (`boolean`, 선택): 예약 발송 여부 (기본 `false`) - `scheduledAt` (`string`, 선택): 예약 일시 (`"YYYY-MM-DD HH:mm"`, KST, 최소 30분 이후) - `recipients` (`array`, **필수**, 최소 1건): `[{ "phoneNumber": "01012345678", "recipientName": "홍길동" }]` - `attachmentFileIds` (`number[]`, 선택): MMS 전용 이미지 `fileId` 배열 (최대 3개) - 성공 응답(`200`): ```json { "status": true, "message": "문자 발송 접수가 완료되었습니다.", "data": { "campaignId": 90210, "status": "DISPATCHED", "messageType": "SMS", "purpose": "INFO", "requestedCount": 1, "excludedUnsubscribeCount": 0, "invalidSkippedCount": 0, "acceptedCount": 1, "appliedUnitPrice": 8.40, "totalChargeAmount": 8.40, "remainingBalance": 154191.60, "scheduledAt": null }, "timestamp": 1793716522000 } ``` ### 5.7 캠페인 발송 상태 · 결과 조회 — `GET /api/open/v1/campaigns/{campaignId}` - 인증: 필수 (`토큰당 30회/분`) - 성공 응답(`200`): ```json { "status": true, "message": "캠페인 발송 상태가 조회되었습니다.", "data": { "campaignId": 90210, "source": "API", "status": "COMPLETED", "isScheduled": false, "messageType": "SMS", "senderNumber": "15334005", "totalCount": 100, "successCount": 98, "failCount": 2, "pendingCount": 0, "appliedUnitPrice": 8.40, "totalChargeAmount": 840.00, "failRefundAmount": 16.80, "isRefunded": true, "createdAt": "2026-11-04T10:15:00" }, "timestamp": 1793716522000 } ``` - `status` 값: `"SCHEDULED"`(예약 대기) | `"SENDING"`(전송 진행 중) | `"COMPLETED"`(결과 집계 완료) | `"CANCELLED"`(예약 취소됨) - `source` 값: `"WEB"`(콘솔 발송) | `"API"`(REST API 발송) | `"MCP"`(AI Remote MCP 발송) ### 5.8 예약 캠페인 취소 · 즉시 환급 — `POST /api/open/v1/campaigns/{campaignId}/cancel` - 인증: 필수 (`토큰당 30회/분`) - Request Body (`application/json`, 선택): `{ "reason": "이벤트 일정 변경" }` - 성공 응답(`200`): ```json { "status": true, "message": "예약 발송이 취소되었으며 차감 예치금이 즉시 환급되었습니다.", "data": { "campaignId": 90211, "status": "CANCELLED", "refundedAmount": 2500.00, "remainingBalance": 156691.60, "cancelledAt": "2026-11-04T11:00:00" }, "timestamp": 1793716522000 } ``` ### 5.9 MMS 이미지 업로드 — `POST /api/open/v1/mms/images` - 인증: 필수 (`토큰당 10회/분`) - Content-Type: `multipart/form-data` (`file` 파트 필수 — **JPG/JPEG 전용, 300KB 이하 권장**) - 성공 응답(`200`): ```json { "status": true, "message": "MMS 이미지가 업로드되었습니다.", "data": { "fileId": 508, "originalFilename": "invite.jpg", "byteSize": 98412, "width": 640, "height": 480 }, "timestamp": 1793716522000 } ``` --- ## 6. 표준 오류 코드 표 (Error Codes) | HTTP Status | `error` 코드 | 발생 원인 및 권장 처리 | | :---: | :--- | :--- | | `400` | `INVALID_REQUEST` | 필수 파라미터 누락, EUC-KR 바이트 초과(90B/2,000B), 미등록 발신번호 → 입력값 수정 | | `400` | `AD_POLICY_VIOLATION` | 광고 문자 야간(20:45~08:00) 발송 시도 또는 `(광고)`·`080` 누락 → 시간대 확인 및 `autoFormatAd: true` 사용 | | `400` | `BLOCKED_WORD` | 불법스팸·피싱 방지 금칙어 포함 → 본문 수정 | | `400` | `SCHEDULE_TOO_SOON` | 예약 시각이 현재로부터 30분 미만 → 최소 30분 이후 시각으로 설정 | | `401` | `INVALID_TOKEN` | 토큰 오입력·폐기 또는 미허용 서버 공인 IP에서 호출 → 콘솔 토큰 및 허용 IP 확인 | | `403` | `SEND_RESTRICTED` | 계정/상품 발송 권한 제한, 심사중·만료 발신번호 사용, 채널 점검 중 | | `404` | `NOT_FOUND` | 잘못된 API 경로 또는 존재하지 않는 `campaignId` 조회 | | `409` | `INSUFFICIENT_BALANCE` | 예치금 잔액 부족 → 콘솔 예치금 충전 후 재시도 | | `409` | `CANCEL_DENIED` | 발송 예정 5분 이내이거나 이미 발송 완료·취소된 캠페인 취소 시도 | | `429` | `RATE_LIMITED` | 분당 호출 한도 초과 → 응답 헤더 `Retry-After`(초)만큼 대기 후 재시도 | | `429` | `QUOTA_EXCEEDED` | 계정 1회 동보 한도 또는 일일/월간 발송 한도 초과 | | `500` | `INTERNAL_ERROR` | 일시적 서버 내부 오류 → 지수 백오프 재시도 (중복 발송 주의) | | `503` | `SERVICE_UNAVAILABLE` | 정기 점검 상태 | --- ## 7. 언어별 프로덕션 표준 연동 코드 (429 Retry-After 포함) ### 7.1 Node.js / TypeScript (Fetch API, Node 18+) ```typescript const PINCO_BASE_URL = process.env.PINCO_BASE_URL ?? "https://sms-api.pinco.kr"; const PINCO_API_TOKEN = process.env.PINCO_API_TOKEN; export interface PincoRecipient { phoneNumber: string; recipientName?: string; } export async function sendPincoSms(params: { senderNumber: string; messageType: "SMS" | "LMS" | "MMS"; body: string; recipients: PincoRecipient[]; purpose?: "INFO" | "AD" | "ELECTION"; subject?: string; companyName?: string; autoFormatAd?: boolean; }) { if (!PINCO_API_TOKEN) { throw new Error("PINCO_API_TOKEN 환경변수가 설정되지 않았습니다."); } for (let attempt = 1; attempt <= 2; attempt++) { const res = await fetch(`${PINCO_BASE_URL}/api/open/v1/messages/send`, { method: "POST", headers: { "Content-Type": "application/json", "X-Pinco-Api-Token": PINCO_API_TOKEN, }, body: JSON.stringify(params), signal: AbortSignal.timeout(10_000), }); const json = await res.json(); if (res.status === 429 && json.error === "RATE_LIMITED" && attempt === 1) { const waitSec = Math.max(1, Number(res.headers.get("Retry-After") ?? "5")); await new Promise((r) => setTimeout(r, waitSec * 1000)); continue; } if (!res.ok || !json.status) { throw new Error(`[${json.error ?? res.status}] ${json.message}`); } return json.data; } } ``` ### 7.2 Java 21 / Spring Boot 3.2+ (`RestClient`) ```java import org.springframework.http.MediaType; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientResponseException; import java.util.List; import java.util.Map; public class PincoSmsClient { private final RestClient restClient; public PincoSmsClient(String apiToken) { this.restClient = RestClient.builder() .baseUrl("https://sms-api.pinco.kr") .defaultHeader("X-Pinco-Api-Token", apiToken) .build(); } public Map sendOtp(String senderNumber, String phoneNumber, String otpCode) throws InterruptedException { Map payload = Map.of( "senderNumber", senderNumber, "messageType", "SMS", "purpose", "INFO", "body", "[핑코비즈] 인증번호 [" + otpCode + "]를 입력해 주세요.", "recipients", List.of(Map.of("phoneNumber", phoneNumber)) ); for (int attempt = 1; attempt <= 2; attempt++) { try { return restClient.post() .uri("/api/open/v1/messages/send") .contentType(MediaType.APPLICATION_JSON) .body(payload) .retrieve() .body(Map.class); } catch (RestClientResponseException ex) { if (ex.getStatusCode().value() == 429 && attempt == 1) { String retryHeader = ex.getResponseHeaders() != null ? ex.getResponseHeaders().getFirst("Retry-After") : null; long waitSec = (retryHeader != null && retryHeader.matches("\\d+")) ? Long.parseLong(retryHeader) : 5L; Thread.sleep(waitSec * 1000L); continue; } throw new RuntimeException("PingcoBiz API 실패: " + ex.getResponseBodyAsString(), ex); } } throw new IllegalStateException("Rate limit 재시도 횟수 초과"); } } ``` ### 7.3 Python 3.10+ (`requests`) ```python import os import time import requests PINCO_BASE_URL = os.getenv("PINCO_BASE_URL", "https://sms-api.pinco.kr") PINCO_API_TOKEN = os.environ["PINCO_API_TOKEN"] def send_pinco_message(sender_number: str, phone_number: str, text: str, msg_type: str = "SMS") -> dict: payload = { "senderNumber": sender_number, "messageType": msg_type, "purpose": "INFO", "body": text, "recipients": [{"phoneNumber": phone_number}], } for attempt in (1, 2): res = requests.post( f"{PINCO_BASE_URL}/api/open/v1/messages/send", headers={"X-Pinco-Api-Token": PINCO_API_TOKEN}, json=payload, timeout=(3.0, 10.0), ) body = res.json() if res.status_code == 429 and body.get("error") == "RATE_LIMITED" and attempt == 1: time.sleep(max(1, int(res.headers.get("Retry-After", "5")))) continue if not res.ok or not body.get("status"): raise RuntimeError(f"[{body.get('error', res.status_code)}] {body.get('message')}") return body["data"] raise RuntimeError("재시도 횟수 초과") ```