계정마다 발급되는 API 키 하나로 POST /api/send를 호출하면, 로그인된 휴대폰에서 실제로 문자가 발송됩니다.
비밀번호나 PC 웹 로그인 세션과는 무관한 별도 자격증명이며, 이 키로 할 수 있는 일은 "문자 발송·예약 발송·발송 결과 조회"(11~12항)와,
휴대폰 앱에서 허용한 경우의 "대화 조회·읽음 처리"(9항)·"연락처 검색"(13항)뿐입니다 — 연락처 수정·삭제, 통화기록 열람 등 다른 기능에는 쓸 수 없습니다.
구독하지 않은 계정은 키를 발급받아도 402 premium_required 오류가 반환됩니다.
보내기 전에 결과를 미리 보고 싶다면 POST /api/send/check(문자를 보내지 않는 사전 검사),
남은 한도가 궁금하다면 GET /api/usage를 쓰세요 — 둘 다 6항에 있습니다.
보낸 문자의 상태는 GET /api/messages/status(11항), 예약 발송은 /api/schedules(12항),
연락처 검색은 GET /api/contacts/search(13항)입니다.
501 feature_unavailable을 돌려줍니다
(GET /api/usage의 phone.features로 호출 전에 확인할 수 있습니다).
기존 발송·대화 조회는 예전 버전에서도 그대로 동작합니다.501 feature_unavailable로 응답합니다. 고장이 아니라 그 휴대폰에 아직 업데이트가 닿지 않은 것이며,
앱이 업데이트되면 코드를 바꾸지 않아도 그대로 동작합니다. 연동을 지금 만들어 두려면 501을
"이 계정은 아직 못 씀"으로 처리하고 phone.features를 보고 기능을 켜세요.API 키는 PC 웹(twophone.co.kr/app)에 로그인한 뒤, 좌측 하단 프로필 버튼을 눌러 열리는 화면에서 확인·복사·재발급할 수 있습니다. 앱에서는 확인할 수 없습니다.
키가 유출됐다고 의심되면 같은 화면에서 재발급하세요 — 기존 키는 그 즉시 무효화됩니다.
POST https://twophone.co.kr/api/send
Authorization: Bearer <API 키>
Content-Type: application/json
{
"address": "01012345678",
"body": "안녕하세요, 주문하신 상품이 발송되었습니다.",
"lineId": "ESIM_MAIN"
}
| 필드 | 필수 | 설명 |
|---|---|---|
address | 예 | 수신 번호 |
body | 예 | 문자 내용 |
advertisement | 아니오 | 이 문자가 영리 목적의 광고·홍보인지 선언합니다. true로 선언하면 서버가 (광고) 표기 누락을 잡아 주고, 필요한 법적 고지를 notice로 실어 줍니다. 선언한다고 발송이 막히지는 않습니다(시간대 제한 없음). 자세한 내용은 아래 7항. |
idempotencyKey | 아니오 | 중복 발송 방지 키. 같은 키로 10분 안에 다시 호출하면 문자를 다시 보내지 않고 첫 번째 결과(messageId 포함)를 그대로 돌려줍니다. 표준 Idempotency-Key 헤더로 넣어도 동일하게 동작합니다(본문 값이 우선). 재시도할 때는 반드시 처음과 같은 값을 쓰세요. 1~128자이며 공백만 있는 값은 400 invalid_idempotency_key입니다. |
lineId | 아니오 | 발신 회선. 아래 4개 값 중 하나(대소문자 정확히 일치). 생략하면 휴대폰이 기본 발신 회선을 스스로 정해서 그 회선으로 보내고 그 회선으로 기록합니다 — 그 번호와 이미 있는 대화방의 회선 → 연락처에 지정된 기본 발신 번호 → 마지막으로 문자에 쓴 회선 → 시스템 기본 SMS 회선 순입니다(사용자가 앱에서 직접 보냈을 때와 같은 회선). 특정 회선으로 보내야 한다면 반드시 넣어주세요. |
| 값 | 의미 |
|---|---|
PHYSICAL_MAIN | uSIM 메인 |
PHYSICAL_DUAL | uSIM 듀얼(통신사 부가번호) |
ESIM_MAIN | eSIM 메인 |
ESIM_DUAL | eSIM 듀얼(통신사 부가번호) |
{ "ok": true, "messageId": 123,
"limits": {
"minute": {"used":3,"limit":10,"remaining":7,"resetInSeconds":42,"resetAt":"2026-10-15T14:21:00+09:00","exhausted":false},
"hour": {"used":3,"limit":40,"remaining":37,"resetInSeconds":3102,"resetAt":"…","exhausted":false},
"day": {"used":3,"limit":100,"remaining":97,"resetInSeconds":35000,"resetAt":"…","exhausted":false}
},
"limitsScope": "server" }
limits는 429 rate_limited 응답에 실리는 것과 완전히 같은 구조이므로,
성공·실패에 따라 파서를 두 벌 쓸 필요가 없습니다. 성공 응답의 숫자는 이번 발송을 반영한 뒤의
잔량입니다(막히고 나서야 잔량을 아는 대신, 매 응답으로 다음 호출 간격을 정하세요).
POST /api/send·POST /api/send/check·POST /api/schedules·
GET /api/usage·웹훅(10항)·MCP 발송 도구가 모두 같은 값을 돌려줍니다.
한도가 적용되지 않는 응답에서는 limits가 빈 객체({})입니다.limitsScope — 이 숫자는 누가 센 것인가.
"server"는 중개 서버가 센 계정 단위 한도입니다. 여유가 남아 있어도
휴대폰이 회선 단위 한도로 추가로 막을 수 있습니다(아래 6항 "최종 판정은 휴대폰이 합니다").
휴대폰이 막은 거절에는 limitsScope: "phone"과 함께 휴대폰이 센 숫자가 실리고,
같은 값이 phoneLimits에도 들어갑니다 — 두 층을 한 장부에 섞지 않으려면 이 필드로 구분하세요.
limitsScope가 없는 응답은 이 기능 이전의 서버입니다.
휴대폰이 수신처 수까지 셌다면 phoneRecipients가 함께 옵니다(6항
수신처 축). 창과 단위가 다른 별개의 축이라
limits 안이 아니라 최상위에 따로 실립니다.
광고 규제 관련 고지가 있으면 성공·실패와 무관하게 notice(사람이 읽는 문장)와
noticeCodes(같은 고지의 기계용 코드) 배열이 함께 실립니다(아래 6·7항).
기존 필드는 그대로이므로 이 필드를 모르는 호출자는 무시해도 됩니다.
{ "ok": true, "messageId": 123,
"notice": ["지금은 21시~08시입니다. … TwoPhone 은 이 발송을 막지 않았습니다 …"],
"noticeCodes": ["ad_night_advisory"] }
| 상태 코드 | 본문 | 의미 |
|---|---|---|
| 400 | {"ok":false,"error":"invalid_address"} | 수신 번호가 전화번호 형식이 아님. 하이픈·공백·+82 표기는 자동으로 정리되지만, 이름이나 메모가 섞이면 거절됩니다. |
| 400 | {"ok":false,"error":"empty_body"} · "body_too_long" · "unknown_line" | 내용 누락 / 2000자 초과 / 알 수 없는 lineId |
| 401 | - | API 키가 없거나 잘못됨 |
| 402 | {"ok":false,"error":"premium_required"} | 구독이 필요한 계정 |
| 400 | {"ok":false,"error":"invalid_idempotency_key"} | idempotencyKey가 공백뿐이거나 128자를 넘음 |
| 400 | {"ok":false,"error":"ad_label_missing"} | advertisement: true로 선언했는데 본문이 (광고)로 시작하지 않음. 응답의 detail에 고칠 문안 예시가 들어 있습니다. 광고 관련으로 발송이 거절되는 유일한 경우이며, 시간대와는 무관합니다. |
| 409 | {"ok":false,"error":"duplicate_send","retryAfterSeconds":…} | idempotencyKey 없이 같은 번호에 같은 내용을 1분 안에 다시 보냄(처음 발송에 키가 있었어도 해당). 재시도라면 이미 발송된 것이니 다시 보내지 마세요. 의도적 재발송은 매번 다른 idempotencyKey를 지정하면 통과합니다. |
| 409 | {"ok":false,"error":"idempotency_key_reuse"} | 같은 idempotencyKey로 이전과 다른 내용(수신 번호 또는 본문)을 보내려 함. 이 요청은 발송되지 않았습니다. 새 문자라면 다른 키를 쓰고, 재시도라면 처음과 똑같은 번호·본문으로 보내세요. |
| 429 | {"ok":false,"error":"rate_limited","retryAfterSeconds":37, | 발송 한도 초과. 한도는 구독 등급별로 다릅니다(6항 표). limitWindow는 어느 창에서 걸렸는지(minute·hour·day), limits는 창별 remaining·resetAt입니다 — day에 걸렸다면 자정까지 기다려야 하므로 재시도 간격을 그에 맞게 잡으세요. Retry-After 헤더에는 retryAfterSeconds와 같은 값이 실립니다. 휴대폰이 막은 거절도 같은 모양으로 오며(limitsScope: "phone"), 그때는 아래 retryAfterUnknown 설명을 반드시 읽으세요. |
| 501 | {"ok":false,"error":"feature_unavailable","feature":"schedules"} | 연결된 휴대폰 앱이 그 기능을 모르는 예전 버전(11~13항 전용). 설정 문제가 아니라 앱 업데이트가 필요한 상태이므로, 502 agent_rejected(사용자가 스위치를 꺼 둠)와 반드시 구분해서 처리하세요 — 재시도해도 결과가 같습니다. |
| 503 | {"ok":false,"error":"device_offline"} | 휴대폰이 꺼져있거나 절전 상태. 서버가 푸시로 깨우기를 시도하지만 이 요청 안에서 깨어난다는 보장은 없습니다 — 잠시 후 재시도하세요. |
| 504 | {"ok":false,"error":"timeout"} | 20초 안에 휴대폰이 응답하지 않음. 재시도하세요. 같은 idempotencyKey의 앞선 요청이 아직 진행 중인데 25초를 넘겨도 이 응답이 옵니다(두 번 보내는 위험 대신 기다리다 끊는 쪽을 택합니다) — 이때도 문자는 한 통만 나갑니다. |
| 200 | {"ok":false,"error":"..."} | 휴대폰이 발송을 시도했지만 실패(사유 포함) |
curl -X POST https://twophone.co.kr/api/send \
-H "Authorization: Bearer tp_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"address":"01012345678","body":"안녕하세요"}'
한도는 사용량 배급이 아니라 사고 방지 장치입니다. 오작동한 스크립트나 AI 에이전트가 문자를 무한정 보내는 상황을 끊고, 무엇보다 통신사의 일일 발송 상한을 넘겨 회선이 차단되는 일을 막는 것이 목적입니다.
왜 낮췄는가. 국내 통신사는 회선당 하루 500건을 넘기면 발신을 차단합니다(통신사에 따라 1개월, 보유 전체 회선·IMEI 3개월). 종전 하루 2,000건은 그 차단 지점의 네 배였고, 분당 200건이면 통신사 상한을 2분 30초에 소진합니다. 저희 한도를 통과한 발송이 통신사에서 막히면 회선 자체가 정지되므로, 저희가 먼저 멈추는 쪽으로 바꿨습니다. 자세한 근거는 문자는 하루 몇 건까지 보낼 수 있나에 있습니다.
하루 2,000건이 필요하셨다면. 상위 등급으로도 복구되지 않습니다 — Business 의 하루 400건도 통신사 상한(500)에서 역산한 값이라, 어떤 등급으로도 그 위로 올라가지 않습니다. 회선을 나누거나(회선마다 별도 계정) 통신사의 대량 발송 서비스를 쓰셔야 합니다. 상황을 알려 주시면 함께 방법을 찾겠습니다 — 문의하기.
phoneLimits·limitsScope: "phone"으로 따로 옵니다(아래 "최종 판정은 휴대폰이 합니다").
덧붙여, 통신사의 투넘버·듀얼번호는 모회선과 한도를 공유합니다 — 번호가 둘이어도 통신사 장부에서는 한 회선입니다.
| 등급 | 분당 | 시간당 | 하루 |
|---|---|---|---|
| 무료 | API·MCP를 쓸 수 없습니다(402 premium_required). 한도가 0건인 것이 아니라 기능 자체가 구독 전용입니다. | ||
| Plus | 10건 | 40건 | 100건 |
| Pro | 20건 | 80건 | 250건 |
| Business | 30건 | 120건 | 400건 |
| 중복 차단 | 1분 — 같은 번호 + 같은 내용의 반복. 루프·크론 오설정을 두 번째 발송에서 끊습니다(등급과 무관). | ||
등급을 가르는 것은 발송량만이 아닙니다. 아래는 통신사와 무관한 우리 제품 정책이고, 막는 이유도 다릅니다 — 발송 한도는 통신사가 회선을 차단하는 것을 막는 장치지만, 아래 둘은 순수한 서버 자원 보호입니다(넘겨도 회선 차단도 과금도 없습니다). 그래서 팀 규모에 비례해 올라갑니다.
| 등급 | 조회 한도(분당) | 웹훅 동시 전송 | 웹훅 대기 |
|---|---|---|---|
| 무료 | 60 | API·MCP·웹훅이 구독 전용입니다(402 premium_required) | |
| Plus | 60 | 4 | 64 |
| Pro | 180 | 8 | 128 |
| Business | 600 | 16 | 256 |
| 웹훅 자동 중단 | 연속 20회 실패 → 10분간 전송 중단, 그 뒤 1건으로 탐색(등급과 무관 — 자원 배분이 아니라 죽은 URL 보호입니다). | ||
조회 한도는 9항의 대화 조회 API와 MCP 조회 도구가 함께 쓰는 예산이며,
발송 한도와는 완전히 별개입니다(조회를 많이 해도 문자는 그대로 나갑니다).
지금 남은 양은 GET /api/usage 의 read.minute 에 있습니다.
웹훅 동시·대기는 받는 쪽이 느릴 때 버티는 양입니다 — 자리가 차면 버리지 않고
대기 줄에 세우고(최대 30초), 줄까지 차야 버립니다(10항).
현재 등급과 지금 남은 양은 GET /api/usage의 tier·send에서
그대로 확인할 수 있습니다. 한도를 추측해 코딩하지 말고 그 값으로 페이싱하세요.
조회를 따로 하지 않아도 됩니다 — 발송·예약·사전 검사 응답과 웹훅 본문에 같은 값이 항상 실립니다(4항).
| 창 | 리셋 방식 | 소진되면 |
|---|---|---|
| 분 · 시간 | 첫 발송 시각 기준 롤링(매분 00초 같은 고정 경계가 아닙니다 — 경계에 몰아 쓰는 우회를 막기 위해서입니다) | 그 창이 끝날 때까지 대기. 응답의 limits.minute.resetAt·resetInSeconds가 정확한 시각을 알려 줍니다 |
| 하루 | 한국 시간(KST) 자정 고정 | 다음 날 0시까지. limits.day.resetAt이 그 시각입니다 |
세 창은 중첩되며 안쪽(분)이 먼저 소진됩니다. 안쪽 창에 걸린 요청은 바깥 창의 예산을 쓰지 않습니다 —
분당 한도에 막혀 429를 받은 요청들 때문에 하루치가 줄어드는 일은 없으니, Retry-After가 있으면 그 값만 지켜 재시도하면 됩니다.
retryAfterUnknown — 언제 풀리는지 모르는 429Retry-After 헤더와 retryAfterSeconds가 모두 없고
retryAfterUnknown: true만 있는 응답이 있습니다. limitWindow도 없거나
minute·hour·day가 아닌 이름일 수 있습니다.
상태 코드는 429일 수도, 200(+ok:false)일 수도 있습니다 —
휴대폰이 한도로 막으면 전자, 권한·등급처럼 한도가 아닌 사유로 막으면 후자입니다(4항 표).
어느 쪽이든 해야 할 일은 같습니다.
휴대폰이 창이 아닌 축(예: 한 달 동안 문자를 보낸 수신처 수)으로 막았다는 뜻이며, 기다려서 풀리는 거절이 아닐 수 있습니다(월 단위 축은 다음 달에야 바뀝니다). 서버는 이때 재시도 시각을 지어내지 않습니다 — 임의로 1초를 채워 보내면 자동화가 몇 주 동안 초당 재시도 루프를 돌기 때문입니다.
호출자가 할 일: 이 응답을 받으면 재시도하지 말고
detail과 phoneRecipients를 사람에게 보여주세요. 재시도 백오프를 자동으로 돌리는
클라이언트라면 retryAfterUnknown을 "재시도 금지"로 분기하세요.
반대로 retryAfterSeconds가 있는 429는 종전 그대로 그 값만 지키면 됩니다.
phoneRecipients — 수신처(상대 번호) 축휴대폰이 셀 수 있었을 때만 응답에 실리는 최상위 필드입니다. limits·phoneLimits와 단위가 다릅니다 —
창은 「보낸 건 수」이고 이쪽은 「문자를 보낸 수신처(상대 번호) 수」입니다. 그래서 같은 맵에 섞지 않고 따로 보냅니다.
"phoneRecipients": {
"dailyRecipients": {"used":40, "limit":500, "remaining":460, "resetInSeconds":30000, "exhausted":false, "presumption":false},
"monthlyRecipients": {"used":2000,"limit":2000,"remaining":0, "resetInSeconds":1209600, "exhausted":true, "presumption":true}
}
presumption: true는 "넘으면 차단"이 아닙니다.
통신사 약관에서 월 수신처 수는 상한이 아니라 "상업적 목적의 대량 발송으로 간주할 수 있다"는 추정 규정입니다.
이 값을 "차단선"으로 안내하면 사용자는 보낼 수 있는 문자를 포기하게 됩니다 — 사용자에게 보여줄 때는
"통신사가 상업적 목적으로 볼 수 있는 기준에 도달했다"로 전하세요.
presumption: false인 축(일 수신처)은 약관의 실제 상한입니다.
휴대폰이 세지 못했으면 키 자체가 없습니다(0으로 채워 보내지 않습니다 — "아무 곳에도 안 보냈다"는 거짓이 되기 때문입니다).
통신사 약관에 있는 축은 셋뿐입니다 — ① 하루 발송 건수 상한(3사 모두 500건 수준이며, 넘기면 발신이 차단됩니다) ② 무료 제공량(FUP) 기준을 넘긴 날이 한 달에 여러 번 반복될 때의 과금 전환 ③ 한 달 동안 문자를 보낸 수신처(상대 번호) 수. 약관에는 분당·시간당 조항이 없습니다.
따라서 위 표의 분당·시간당은 통신사 규정이 아니라 TwoPhone의 페이싱 장치입니다. 폭주 루프가 몇 분 만에 하루치를 태우는 것을 막기 위한 값이며, 통신사 규정으로 인용하지 마세요. 반대로 하루 한도는 통신사의 하루 상한 아래에 머물도록 잡은 값입니다 — 회선 차단을 막는 선이라 등급을 올리는 것 말고는 늘릴 수 없습니다.
통신사 3사의 실제 숫자(하루 500건, FUP 기준, 월 수신처)와 약관 조항 번호, 차단과 과금 전환의 차이, 한도에 걸렸을 때의 해제 창구는 문자는 하루 몇 건까지 보낼 수 있나에 정리해 두었습니다.
429 에 error:"rate_limited"·
limitsScope:"phone" 과 휴대폰이 센 숫자가 함께 실립니다(아래 참조).
그 밖의 사유는 200 + {"ok":false,"error":"폰이 준 코드"}
로 옵니다. 상태 코드를 429 로 바꾸지 않습니다 — 기다려서 풀리는 거절이
아닌데 429 로 답하면 호출자가 "조금 뒤에 다시"로 오해하고 영원히 재시도하기 때문입니다. 대신
error 에는 휴대폰이 준 코드가 그대로 실리고, 사람이 읽는 사유 문장은
detail 에 옵니다. 지금 오는 코드는 아래 둘이며, 앞으로 늘어날 수 있으니 모르는
값도 처리하세요(모르는 코드도 가공 없이 그대로 전달됩니다).
error | 뜻 | 호출자가 할 일 |
|---|---|---|
tier_required | 이 구독 등급에는 API·AI 연동 발송이 없습니다 | 재시도하지 마세요. 기다려도 풀리지 않습니다 — 사용자에게 상위 등급을 안내하세요 |
quota_unmeasurable | 휴대폰이 문자 저장소를 읽지 못해 오늘 보낸 양을 세지 못했습니다(대개 문자 권한) | 사용자가 고칠 수 있습니다. 휴대폰 앱의 문자 권한을 확인해 달라고 안내한 뒤 다시 시도하세요 |
phone_permission_required | 휴대폰이 전화 권한을 잃어 어느 회선으로 보낼지 식별하지 못했습니다 | 사용자가 고칠 수 있습니다 — 휴대폰 설정에서 전화 권한을 다시 켜 달라고 안내하세요(위 quota_unmeasurable과 켜야 할 스위치가 다릅니다). 이 상태에서도 사용자가 손으로 보내는 1:1 문자는 멀쩡히 나가므로, 알려주지 않으면 이상을 눈치채지 못합니다 |
| 그 밖의 값 | 이 문서보다 새로운 휴대폰 앱이 준 코드 | detail 을 사람에게 보여주세요. 뜻을 모르면 재시도하지 마세요 |
이 거절들에는 retryAfterUnknown: true가 함께 옵니다
(retryAfterSeconds와 Retry-After는 없습니다). 시간이 풀어 주는 원인이
아니라 사용자가 무언가를 해야 풀리는 거절이기 때문입니다 — 서버는 여기에 재시도 시각을
지어내 채우지 않습니다. 재시도 백오프를 자동으로 도는 클라이언트는
retryAfterUnknown을 "재시도 금지"로
분기하세요(429 에서와 같은 규칙이므로 코드를 따로 쓸 필요가 없습니다).
아주 오래된 휴대폰 앱은 코드를 싣지 않습니다. 그때만 종전처럼
error 에 사유 문장이 들어옵니다 — 그래서 error 로 분기할 때는
알고 있는 코드와 일치하는지를 보고, 그 밖은 전부 "알 수 없는 실패"로 처리하세요.
어느 경우든 ok 필드를 반드시 확인하세요:
"성공 응답(200)을 받았는데 문자가 안 갔다"는 바로 이 경우입니다.
그래서 응답에 실리는 한도에는 어느 층의 숫자인지가 함께 옵니다:
limitsScope: "server"는 위의 1차(계정 단위) 한도이고, 휴대폰이 한도로 막은 거절에는
limitsScope: "phone"과 함께 휴대폰이 센 회선 단위 숫자가 limits·phoneLimits에
실립니다(그 거절은 429 rate_limited로, 서버가 자기 한도로 막을 때와 같은 모양입니다).
서버 잔량이 남았다는 것은 "서버가 막지 않는다"는 뜻이지 "반드시 발송된다"는 뜻이 아닙니다.
휴대폰은 창(분·시·일) 말고 수신처 축으로도 막을 수 있습니다. 그 거절에는
phoneRecipients가 함께 오고, 언제 풀리는지 알 수 없을 때가 있습니다 —
그때 응답이 어떻게 생겼고 무엇을 해야 하는지는 retryAfterUnknown에 적어 두었습니다.
/api/send와 MCP)에만 적용됩니다. 다만 통신사는 그 문자들도 함께 세므로,
사람이 직접 많이 보낸 날에는 휴대폰 단계에서 먼저 막힐 수 있습니다.
중복 차단은 발송에 성공한 건만 기억합니다(idempotencyKey가 있던 발송도 기억합니다).
503·504로 실패한 뒤의 재시도는 중복으로 보지 않으므로 안심하고 재시도하세요. 검사는 idempotencyKey가
없는 요청에만 합니다 — 같은 키로 재시도하면 차단 대신 처음 결과가 그대로 돌아오고, 같은 내용을 의도적으로 한 번 더
보내야 한다면 idempotencyKey에 서로 다른 값을 지정하면 통과합니다. 처음에 키를 넣어 보내고 재시도에서 키를 빼면
409로 막힙니다.
idempotencyKey)같은 키로 10분 안에 다시 호출하면 문자를 다시 보내지 않고 처음 결과를 그대로 돌려줍니다.
이때 응답에 Idempotency-Replay: true 헤더가 붙으므로, "이번 호출로 새로 나간 문자는 없다"를
확실히 구분할 수 있습니다(모르는 헤더는 무시해도 기존 동작 그대로입니다).
409 idempotency_key_reuse로 거절되고
아무것도 발송되지 않습니다. 예전에는 이 경우 조용히 첫 결과를 돌려줘서, 호출자는
200과 messageId를 받고 "보냈다"고 믿었지만 실제로는 문자가 나가지 않았습니다
(발견하기 가장 어려운 실패라 오류로 바꿨습니다). 키마다 내용이 달라지는 정상적인 호출자는 이 경로를 만나지 않습니다./api/send와 똑같은 기준(번호 형식·본문 길이·회선·광고 규정·중복 차단·발송 한도)으로
검사하되 문자를 보내지 않습니다. 한도를 소모하지 않고, 중복 차단 기록을 남기지 않으며,
휴대폰을 깨우지도 않습니다. 요청 본문은 /api/send와 같은 형태입니다.
curl -X POST https://twophone.co.kr/api/send/check \
-H "Authorization: Bearer tp_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"address":"01012345678","body":"(광고) 주말 할인 …","advertisement":true}'
{ "wouldSend": true,
"normalizedAddress": "01012345678", "bodyLength": 132, "phoneOnline": true,
"notices": ["지금은 21시~08시입니다. … TwoPhone 은 이 발송을 막지 않았습니다 …"],
"noticeCodes": ["ad_night_advisory"], "warnings": [],
"limits": { "minute": {"used":3,"limit":10,"remaining":7,"resetInSeconds":42,"resetAt":"…","exhausted":false}, … },
"limitsScope": "server" }
limits·limitsScope는 /api/send의 응답과 같은 모양입니다(4항).
이 검사는 휴대폰에 묻지도 깨우지도 않으므로 언제나 서버 층("server")이며,
wouldSend: true여도 발송 직전에 휴대폰이 회선 한도로 막을 수 있습니다.
야간 광고는 wouldSend: false가 아닙니다. 21~08시에 (광고) 문자를
검사하면 "보낼 수 있다"로 답하고 noticeCodes에 ad_night_advisory가 실립니다 —
막는 것이 아니라 알리는 것이기 때문입니다(7항).
판정 결과와 무관하게 HTTP 200입니다 — 검사 자체는 성공했고, 결과는 본문의
wouldSend에 있습니다. error 값은 실제로 /api/send가 돌려줄 오류 코드와 같습니다.
phoneOnline이 false여도 wouldSend는 true일 수 있습니다:
실제 발송은 휴대폰을 깨워 최대 20초를 기다리지만, 이 검사는 깨우지 않고 현재 연결 상태만 보기 때문입니다
(그 경우 warnings에 설명이 실립니다).
curl -H "Authorization: Bearer tp_xxxxxxxxxxxxxxxxxxxxxxxx" https://twophone.co.kr/api/usage
{ "premium": true,
"phone": { "online": true, "appVersion": "1.2.2", "appVersionCode": 17,
"features": ["conversations","messages","mark_read","resolve_contact",
"unread_count","search_paging","send_status","schedules","contact_search"] },
"tier": "pro",
"send": { "minute": {"used":3,"limit":20,"remaining":17,"resetInSeconds":42,
"resetAt":"2026-10-15T14:03:21+09:00","exhausted":false},
"hour": {"used":11,"limit":80,"remaining":69,"resetInSeconds":2310,
"resetAt":"2026-10-15T14:41:09+09:00","exhausted":false},
"day": {"used":87,"limit":250,"remaining":163,"resetInSeconds":35040,
"resetAt":"2026-10-16T00:00:00+09:00","exhausted":false} },
"read": { "minute": {"used":4,"limit":60,"remaining":56,"resetInSeconds":31,
"resetAt":"2026-10-15T14:02:10+09:00","exhausted":false} },
"limits": { … },
"limitsScope": "server",
"adNight": { "active": false, "advisoryOnly": true, "endsInSeconds": null, "startsInSeconds": 9000 },
"webhook": { "configured": false } }
limits는 send와 같은 값입니다 — 발송·예약·사전 검사 응답과
웹훅까지 전부 같은 이름을 쓰므로, 호출자는 "응답 어디서든 limits를 읽는다"는 규칙 하나만 들고 있으면 됩니다
(send는 기존 호출자를 위해 그대로 남습니다).
limitsScope는 그 숫자가 어느 층의 것인지를 말합니다 —
이 엔드포인트는 휴대폰을 깨우지 않으므로 언제나 "server"(중개 서버의 계정 단위 한도)입니다.
휴대폰의 회선 단위 한도는 실제 발송 때만 알 수 있고, 그때는 limitsScope: "phone"으로 옵니다(4항).
send의 숫자는 tier(구독 등급)에 따라 달라집니다(6항 표).
resetAt은 그 창이 리셋되는 절대 시각(KST)이고, 열려 있는 창이 없으면 null입니다.
resetInSeconds만 쓰면 응답이 지연된 만큼 어긋나므로, 스케줄러를 만든다면 resetAt을 쓰세요.
adNight.active는 지금이 광고성 문자에 야간 수신 동의가 필요한 시간대(KST 21~08시)인지를
뜻합니다. advisoryOnly는 항상 true입니다 — 이 시간대라고 해서 발송이 막히지 않으며,
active: true를 "지금은 못 보낸다"로 해석하지 마세요(7항).
phone.online은 현재 연결 여부만) 자주 불러도 사용자의 배터리를 쓰지 않으며,
아래 조회 한도의 대상도 아닙니다.phone.features 값 | 이 값이 있어야 쓸 수 있는 것 |
|---|---|
conversations · messages · mark_read · resolve_contact | 9항의 대화 조회·읽음 처리·번호→이름. features가 아예 없는 예전 휴대폰도 이 네 가지는 됩니다 |
send_status | 11항 GET /api/messages/status |
schedules | 12항 /api/schedules 전체 |
contact_search | 13항 GET /api/contacts/search |
unread_count | 대화방 목록 행의 unreadCount(9항) |
search_paging | 검색 결과에도 hasMore·커서가 오는 것(9항) |
휴대폰이 연결돼 있지 않으면(phone.online: false) appVersion·features는
아예 오지 않습니다 — "기능이 없다"가 아니라 "아직 모른다"는 뜻이므로, 휴대폰이 붙은 뒤에 다시 확인하세요.
연동을 개발하다 보면 재시도 로직 실수나 크론 중복 실행으로 같은 문자가 계속 나가는 사고가 생깁니다. 그래서 두 겹으로 막습니다.
알림에는 어떤 번호로 무슨 내용을 보내려 했는지가 담기지 않습니다. 서버가 세는 것은 “차단된 횟수” 하나뿐이며 그마저도 메모리에만 있습니다.
영리 목적의 광고성 정보를 전송할 때는 정보통신망법 제50조가 적용됩니다. 법적 의무의 주체는 전송자, 즉 계정 소유자인 이용자이며, TwoPhone은 이용자의 휴대폰에서 나가는 문자를 중계하는 도구입니다. 어떤 문자가 광고성인지에 대한 최종 판단과 책임은 이용자에게 있습니다 (이용약관 제6조).
(광고) 표기무료수신거부 080-000-0000)(광고) 문자를
보내도 그대로 전송되며, 응답에 ad_night_advisory 고지가 실릴 뿐입니다.
야간 수신 동의를 받아 두었는지는 회원님만 알 수 있는 사실이고, 문자는 회원님의 휴대폰 회선에서
회원님의 이름으로 나갑니다 — 그래서 그 판단과 법적 책임(3천만원 이하의 과태료)은 회원님에게 있습니다.
(2026-09-19 이전에는 이 경우 403 ad_night_ban으로 거절했습니다.
지금은 그 오류를 돌려주지 않습니다.)
요청에 advertisement: true(MCP는 is_advertisement)를 넣으면, 서버가 광고성 문자의
요건을 대신 검사해 회원님이 모르고 위반하는 일을 막아줍니다.
{
"address": "01012345678",
"body": "(광고) 여름 세일 30% 무료수신거부 080-000-0000",
"advertisement": true
}
| 선언했을 때 서버가 하는 일 | 결과 |
|---|---|
(광고) 표기가 없으면 | 400으로 거절하고 고칠 문안을 알려줍니다 (거절하는 유일한 경우) |
| 21~08시면 | 그대로 발송하고 notice + noticeCodes: ["ad_night_advisory"]로 야간 수신 동의 규정과 책임 소재를 알립니다 |
| 무료 수신거부 안내가 없으면 | notice로 알립니다(거절하지 않음 — 문구 형태가 자유로워 오판 위험이 있습니다) |
| 요건을 모두 갖췄으면 | 그대로 발송합니다. 아무 고지도 붙지 않습니다 |
서버는 문자의 내용이 광고인지를 판단하지 않습니다. 그 판단은 자연어 해석이고, 틀리면 정상 문자를 막게 되기 때문입니다. 대신 객관적으로 확인 가능한 표지만 봅니다.
| 상황 | 서버의 처리 |
|---|---|
advertisement: true 선언 + (광고) 표기 없음 |
거절 (400 ad_label_missing) — 회원님이 광고라고 밝혔는데 법이 요구하는 표기가 없는 상태입니다(요청 자체가 앞뒤가 맞지 않습니다). 고칠 문안을 함께 드립니다. 광고 관련으로 발송이 거절되는 유일한 경우이며 시간대와 무관합니다. |
광고(선언했거나 (광고) 표기) + 21~08시 |
발송 + 고지 (ad_night_advisory) — 야간 수신 동의를 받았다면 적법한 발송이고, 받았는지는 서버가 알 수 없습니다. 규정·동의 필요·책임 소재와 "동의가 없다면 오전 8시 이후"라는 대안을 문장으로 알립니다. |
본문이 (광고)로 시작 + 주간 | 발송. 수신거부 안내가 없으면 notice로 알립니다. |
| 표기는 없는데 수신거부 안내가 들어 있음 | 발송 + notice(ad_label_suspected). 광고인데 표기를 빠뜨렸을 가능성이 높지만 단정할 수 없어 막지 않습니다. 야간이면 위법 가능성을 함께 알립니다(ad_night_suspected). |
| 그 외 모든 문자 | 발송. 아무 고지도 붙지 않습니다. |
noticeCodes)notice의 문장은 다듬어질 수 있지만 코드는 계약입니다 — 분기는 코드로 하세요.
| 코드 | 뜻 |
|---|---|
ad_night_advisory | 광고 문자가 21~08시에 나갑니다(또는 그 시각으로 예약됐습니다). 야간 수신 동의가 필요합니다. 차단이 아닙니다. |
ad_schedule_late_advisory | 광고 예약이 20:30~21:00 사이입니다. 절전(도즈)으로 알람이 늦게 울리면 21시를 넘길 수 있습니다. |
ad_label_suspected | 수신거부 안내가 있는데 (광고) 표기가 없습니다. |
ad_night_suspected | 광고로 추정되는 문자가 21~08시에 나갑니다. |
ad_opt_out_missing | 광고인데 무료 수신거부 안내가 보이지 않습니다. |
(광고) 표기를 빼도 얻는 것이 없습니다 — 야간 발송은 어차피 막히지 않고,
표기를 빠뜨리는 것 자체가 위반이기 때문입니다(제50조 제4항).
MCP를 통해 연결된 AI 어시스턴트에게도 같은 내용이 지침으로 전달됩니다.
발송 경로마다 안내가 닿는 방식이 다르지만, 기준과 문구는 모두 같습니다.
| 경로 | (광고) 표기 유도 | 야간 안내 |
|---|---|---|
| PC 웹 단체 문자 | 문자 내용을 입력하면 화면에서 바로 안내하고, 「맨 앞에 (광고) 붙이기」 버튼을 제공합니다. | 야간에는 화면에 경고가 표시됩니다. 발송 버튼은 잠기지 않습니다 — 보낼지 말지는 회원님이 정합니다. |
| 발송 API | 키 발급 시 이용 고지에 동의를 받고, 응답의 notice·noticeCodes 배열로 전달됩니다(성공·실패 모두). advertisement 선언 시 표기가 없으면 고칠 문안과 함께 거절합니다. |
발송하되 ad_night_advisory 고지를 싣습니다. 표기가 없어도 광고로 보이면 ad_night_suspected로 알립니다. |
| MCP (AI 어시스턴트) | 도구 결과에 [사용자에게 반드시 그대로 전달하세요]로 실려 어시스턴트가 사용자에게 전달합니다. 연결 시 지침으로도 전달되며, 어시스턴트는 광고성 문자에 is_advertisement를 선언하고 (광고) 문안을 먼저 제안하도록 지시받습니다. |
API와 동일하게 발송·고지하며, 어시스턴트에게 “서버가 막는다고 말하지 말고, 규정을 그대로 전한 뒤 동의 여부를 확인하고 필요하면 오전 8시 이후를 제안하라”는 지침이 걸려 있습니다. |
TwoPhone은 발송 내용을 저장하지 않습니다. 위 판정은 전송되는 순간 메모리에서만 이뤄지며 기록으로 남지 않습니다(개인정보처리방침 4항).
Claude·ChatGPT·Gemini 같은 AI 어시스턴트가 대화 중에 직접 문자를 보내거나 (허용 시) 문자 대화를 읽게 할 수 있습니다. MCP(Model Context Protocol) 서버를 제공합니다.
| 도구 | 인자 | 하는 일 |
|---|---|---|
send_sms | to, body, line, idempotency_key, is_advertisement | 문자 발송. 취소 불가·요금 발생 |
check_send | to, body, line, is_advertisement | 보내면 어떻게 되는지 보내지 않고 확인(POST /api/send/check와 같은 판정). 한도를 쓰지 않고 휴대폰도 깨우지 않습니다 |
list_lines | - | 휴대폰에 켜져 있는 발신 회선과 사용자가 붙인 이름 |
check_device | - | 휴대폰 연결 확인(꺼져 있으면 깨우기 시도) + 남은 발송 한도·광고 야간 시간대 여부(GET /api/usage와 같은 값. adNight.advisoryOnly는 항상 true — 이 시간대라고 발송이 막히지 않습니다) |
list_conversations | limit(기본 20·최대 50, 검색해도 동일), unread_only, search, cursor | 최근 대화방 목록. 마지막 메시지는 한 줄 미리보기(최대 100자)만 싣습니다. [안읽음 N]은 대화방 전체의 안읽은 수신 수입니다. 결과에 next_cursor가 있으면 그 값을 cursor에 그대로 넣어 다음 쪽을 봅니다. 조회만 하며 읽음 상태를 바꾸지 않습니다 |
get_conversation | thread_id(필수), limit(기본 30·최대 100), mark_read | 한 대화방의 최근 메시지를 오래된 것부터 시간순으로(전문). mark_read를 true로 명시하지 않으면 읽음 상태가 바뀌지 않습니다(기본값 false) |
mark_read | thread_id(필수) | 대화방을 명시적으로 읽음 처리 |
check_new_messages | since(선택, epoch 밀리초) | 지금 안읽은 대화 요약(최근 50개, 한 줄 미리보기). 조회만 하며 읽음 상태를 바꾸지 않습니다 |
get_send_status | message_ids(정수 배열, 1~50개) | 보낸 문자의 상태(11항과 같은 값). 본문·상대 번호는 담기지 않습니다. sent는 실패가 아닙니다 |
schedule_sms | to, body, send_at(ISO-8601 또는 epoch 밀리초), line, is_advertisement, idempotency_key | 예약 발송 등록(12항). 등록이 발송 한도 1건을 소모합니다. 광고는 08:00~20:30 권장(그 밖의 시각도 등록되며 고지가 붙습니다). destructiveHint:false(취소할 수 있으므로) |
list_schedules | - | 대기·실패 예약 목록. "AI가 대화 내용을 읽도록 허용"이 필요합니다(본문이 나가므로) |
cancel_schedule | schedule_id(필수) | 예약 취소(멱등 — 없는 id도 성공) |
search_contacts | query(필수), limit(기본 5·최대 10) | 이름·메모·태그로 연락처 검색 → 이름·번호·기본 발신 회선만. "AI가 연락처를 검색하도록 허용"이 필요합니다(대화 열람과 별개 스위치) |
list_conversations와 check_new_messages는 절대 읽음 처리를 하지 않습니다.
get_conversation도 mark_read 인자를 명시적으로 true로 주지 않는 한
마찬가지입니다(기본값은 false). 대화를 "가져왔다"는 사실만으로 안읽음 표시가 사라지지
않는다는 뜻입니다 — 많은 이용자가 나중에 휴대폰에서 직접 확인하려고 안읽음 표시를 일부러 남겨둡니다.
읽음 처리를 원하면 mark_read 도구를 부르거나(또는 get_conversation에
mark_read: true를 넘겨) 명시적으로 요청해야 합니다. AI 어시스턴트에게도
같은 규칙이 연결 지침(instructions)으로 전달됩니다 — "대화를 읽었다는 이유만으로 읽음 처리를
호출하지 말라"고 명시돼 있습니다.
list_conversations·
check_new_messages·list_lines·check_send·get_send_status·
search_contacts·list_schedules입니다. schedule_sms는 취소할 수 있지만
결국 요금이 나가는 문자이므로 send_sms와 같이 매번 확인하기를 권합니다.
get_conversation은 mark_read 인자를 가지고 있어(그래서 readOnlyHint가 false입니다)
항상 허용으로 두면 어시스턴트가 승인 없이 읽음 처리를 할 수 있고, check_device는 휴대폰 깨우기(푸시)를 유발합니다 —
이 둘은 매번 확인을 권합니다.발송(send_sms)과 달리 대화 조회 도구 4종과 연락처 검색은 기본값이 꺼짐입니다. 휴대폰의
TwoPhone 앱의 설정 > AI 연동에서 아래 4개 스위치 중 필요한 것을 사용자가 직접 켜야 합니다.
| 스위치 | 꺼져 있으면 |
|---|---|
| AI가 대화 내용을 읽도록 허용 | list_conversations/get_conversation/mark_read/check_new_messages와 list_schedules(예약 본문이 나가므로 같은 범주)가 전부 실패하고, 도구 결과에 "앱 설정에서 'AI가 대화 내용을 읽도록 허용'을 켜주세요"라는 안내가 그대로 담깁니다. |
| 새 메시지 도착을 AI 연동 시스템에 알림 | 웹훅(10항)과 MCP GET 스트림(SSE)으로 "새 문자가 왔다"는 알림 자체가 나가지 않습니다. 도구를 직접 부르는 것과는 별개입니다. |
| 알림에 메시지 내용 미리보기 포함 | 위 알림이 나가더라도 문자 본문 없이 "새 메시지가 도착했다"는 사실만 전달됩니다. 위 스위치가 꺼져 있으면 이 스위치는 의미가 없습니다. |
| AI가 연락처를 검색하도록 허용 (신규) | search_contacts와 GET /api/contacts/search(13항)가 실패하고 "앱 설정에서 'AI가 연락처를 검색하도록 허용'을 켜주세요"라는 안내가 그대로 담깁니다. 위의 대화 열람 스위치를 켜도 이 기능은 열리지 않습니다 — 주소록은 대화와 다른 데이터 범주라 별도 동의입니다. |
네 스위치 모두 기본값이 꺼짐입니다 — 사용자가 명시적으로 켜기 전까지는 대화 내용도 연락처도 휴대폰 밖으로 한 글자도 나가지 않습니다. 발송·예약 발송·발송 결과 조회는 사용자가 직접 요청한 동작이라 스위치의 대상이 아닙니다.
URL: https://twophone.co.kr/mcp
헤더: Authorization: Bearer <API 키>
Claude Code 예 (연결 확인은 Claude Code 안에서 /mcp):
claude mcp add --transport http twophone https://twophone.co.kr/mcp \
--header "Authorization: Bearer tp_xxxxxxxxxxxxxxxxxxxxxxxx"
Cursor(~/.cursor/mcp.json) 예 — Gemini CLI(~/.gemini/settings.json)는 url 대신 httpUrl:
{ "mcpServers": { "twophone": {
"url": "https://twophone.co.kr/mcp",
"headers": { "Authorization": "Bearer tp_xxxxxxxxxxxxxxxxxxxxxxxx" } } } }
원격 HTTP를 직접 못 붙이는 stdio 전용 클라이언트(예: Claude Desktop 설정 파일)는 오픈소스 프록시
npx -y mcp-remote https://twophone.co.kr/mcp --header "Authorization:${TWOPHONE_AUTH}"로 연결할 수 있습니다
(환경변수 TWOPHONE_AUTH="Bearer tp_…"). 전용 브리지 패키지 @twophone/mcp는 npm 공개 준비 중입니다.
웹 브라우저에서 쓰는 ChatGPT·Claude는 커스텀 헤더를 넣을 수 없습니다. 그래서 API 키와는 별개인 전용 URL 토큰을 URL 경로에 담아 인증합니다. PC 웹(twophone.co.kr/app) 프로필 화면(2항의 API 키 발급 화면과 같은 곳)에서 이 URL 토큰을 발급받으면, 바로 붙여넣을 수 있는 전체 URL을 함께 보여줍니다.
https://twophone.co.kr/mcp/<토큰>
claude.ai/customize, 예전 설정 › 커넥터 메뉴는 이곳으로 안내됨) → 오른쪽 위 추가 →
커스텀 커넥터 추가 창의 「MCP 서버 URL」에 같은 URL을 붙여넣고 「계속」을 누릅니다(OAuth 관련 칸이 보이면 비워 둠, 메뉴 이름은 바뀔 수 있음). Anthropic 안내 기준 무료 요금제도 쓸 수 있으나 커스텀 커넥터는 1개까지입니다.세션 기반 클라이언트(2025-06-18/2025-11-25 개정)를 지원합니다. initialize 응답에
Mcp-Session-Id 헤더가 함께 오면 이후 요청에 그대로 실어 보내면 되고, 세션을 쓰지 않는
클라이언트는 아무것도 하지 않아도 그대로 동작합니다. Accept: text/event-stream으로
GET 요청을 보내면 서버가 새 문자 도착을 실시간 알림(notifications/message)으로
흘려보냅니다 — 앱의 설정 > AI 연동에서 "새 메시지 도착을 AI 연동 시스템에 알림" 스위치가 켜져 있을 때만 내용이 실립니다.
세션은 30분 동안 요청이 없거나 DELETE로 닫으면 만료됩니다. 만료된(또는 다른 계정의)
Mcp-Session-Id로 보낸 요청은 404를 받으므로, 이때는 initialize부터 다시 보내 새 세션을
받으세요(규약상 클라이언트가 해야 하는 동작입니다).
| 직접 구현할 때 알아둘 것 | 내용 |
|---|---|
| 프로토콜 버전 | 2024-11-05·2025-03-26·2025-06-18·2025-11-25를 그대로 되돌려줍니다. 그 밖의 값은 2025-06-18로 응답합니다 |
| 지원 메서드 | initialize·ping·tools/list·tools/call·resources/list·resources/templates/list·prompts/list. 그 외는 -32601 |
| 배치 금지 | JSON-RPC 배열(여러 건 한 번에)은 -32600으로 거절합니다 — 한 건씩 보내세요 |
| 본문 크기 | 요청 1건당 1MiB. 알림(id 없는 요청)에는 202만 돌려주고 본문이 없습니다 |
| 구조화 결과 | 발송(send_sms)과 mark_read를 뺀 11개 도구는 outputSchema를 선언하고 결과에 structuredContent를 함께 싣습니다. 텍스트만 읽는 클라이언트도 그대로 동작합니다 |
| SSE 동시 연결 | 계정당 4개. 넘으면 503 too_many_streams |
| 브라우저에서 부를 때 | Origin 헤더가 있으면 claude.ai·chatgpt.com·chat.openai.com·gemini.google.com·twophone.co.kr만 허용합니다(그 외 403). 서버-투-서버 호출은 Origin이 없어 영향받지 않습니다 |
| OAuth 없음 | /.well-known/oauth-*는 404입니다. 인증은 API 키 헤더 또는 URL 토큰뿐입니다 |
API 키(Authorization: Bearer)가 틀리면 401, URL 토큰(/mcp/<토큰>)이 틀리면
404를 받으며 둘 다 인증 실패로 셉니다. 같은 IP에서 10분 안에 실패가 10번 쌓이면 그 IP에서 오는
틀린 키·토큰 요청은 마지막 실패로부터 최대 10분 동안 429
({"error":"rate_limited","message":"인증 시도가 너무 많습니다…"})를 받습니다.
올바른 키·토큰으로 보낸 요청은 이 제한과 무관하게 처리됩니다(같은 공인 IP를 쓰는 다른 클라이언트의 실수에
휘말리지 않습니다). 비밀번호를 쓰는 로그인 경로(휴대폰 앱·PC 웹 로그인)는 기존대로 IP 단위로 제한됩니다.
/api/send와 완전히 같습니다 — MCP로 우회해서 더 보낼 수 없습니다(6·7항).send_sms·schedule_sms·
check_send·check_device의 structuredContent.limits와
limits_scope). REST 의 limits와 같은 값·같은 구조이고, 키 이름만 MCP 관례인
snake_case 입니다. 어시스턴트는 그 값을 보고 스스로 발송 간격을 조절하도록 지침을 받습니다 —
limits_scope: "server"는 중개 서버의 계정 한도라 휴대폰이 추가로 막을 수 있다는 뜻입니다(4·6항).[사용자에게 반드시 그대로 전달하세요]와 함께 실립니다. 어시스턴트가 이를 요약하거나 생략하지 않도록 지침이 걸려 있습니다.idempotency_key를 처음과 같은 값으로 넘기면 같은 문자가 두 번 나가지 않습니다.
같은 키에 다른 내용을 넣으면 거절되며(idempotency_key_reuse) 아무것도 발송되지 않습니다.list_conversations·get_conversation·check_new_messages·mark_read)에는
구독 등급별 조회 한도(Plus 분당 60 · Pro 180 · Business 600)가 적용됩니다(6·9항).
새 문자를 실시간으로 알고 싶다면 폴링 대신 GET SSE 스트림이나 웹훅(10항)을 쓰세요.list_conversations·get_conversation·check_device·list_lines·check_new_messages·check_send는
outputSchema를 선언하고 결과에 structuredContent(기계가 읽을 JSON)를 함께 싣습니다.
기존처럼 텍스트만 읽는 클라이언트도 그대로 동작합니다 — 같은 내용이 텍스트 블록에도 들어갑니다.MCP 없이도 같은 발송 API 키로 직접 대화를 조회할 수 있는 서버-투-서버 엔드포인트입니다.
인증·과금 게이트는 /api/send와 동일(API 키 → 구독자 전용)하고, 휴대폰 앱의 설정 > AI 연동에서
"AI가 대화 내용을 읽도록 허용"이 켜져 있어야 실제 응답을 받을 수 있습니다(꺼져 있으면
아래 502/agent_rejected를 받습니다).
| 엔드포인트 | 설명 |
|---|---|
GET /api/conversations | 대화방 목록. 쿼리: limit(기본 30·최대 100), before, beforeId, search, readState(all/unread/read), lineId(전부 선택) |
GET /api/conversations/{threadId}/messages | 한 대화방의 메시지 목록. 쿼리: limit(기본 30·최대 100), beforeId, beforeTs(전부 선택). 읽음 처리를 하지 않습니다 |
POST /api/conversations/{threadId}/read | 대화방을 명시적으로 읽음 처리. 조회 엔드포인트와 완전히 분리돼 있습니다 |
GET /api/contacts/resolve?number= | 번호로 저장된 연락처 이름 조회 — {"saved":true,"lookupKey":"...","displayName":"..."} 또는 {"saved":false} |
429(error·retryAfterSeconds·limitWindow·limits·
limitsScope)를 받습니다 — 파서를 따로 만들 필요가 없습니다. 다만
limitKind: "read"가 함께 오므로 그 limits가 조회 예산이지 발송 예산이
아님을 구분할 수 있습니다(발송 거절에는 이 필드가 없습니다). 두 예산은 완전히 별개라,
조회가 막혀도 발송은 영향받지 않습니다. 지금 남은 조회량은 GET /api/usage 의
read.minute 에 있습니다.
GET /api/usage는 휴대폰을 건드리지 않으므로 이 한도의 대상이 아닙니다 — 한도를 확인하려다 한도에
걸리는 일은 없습니다. 새 문자를 실시간으로 받아야 한다면 폴링 대신 10항의 SSE·웹훅을 쓰세요./api/conversations, /api/conversations/{threadId}/messages 는 휴대폰의
메시지 원본 형식을 그대로 돌려줍니다:
{
"conversations": [
{
"id": 42, "threadId": 5, "address": "01011112222",
"displayName": "김대표", "lineId": "PHYSICAL_MAIN",
"body": "내일 뵙겠습니다", "timestamp": 1757300000000,
"messageType": 1, "isRead": false,
"isMms": false, "hasLocalAttachment": false, "unreadCount": 1
}
],
"hasMore": false, "nextBefore": null, "nextBeforeId": null
}
대화방 목록 항목의 body·timestamp·messageType은 그 대화방의 마지막 메시지 값입니다.
unreadCount는 대화방 전체의 안읽은 수신 메시지 수이고(앱 1.2.2 이상), 이때 isRead는
unreadCount == 0과 같습니다. 앱 1.2.1 이하는 unreadCount를 싣지 않습니다 — 다만
readState=unread로 조회하면 그 버전에서도 isRead가 대화방 단위로 맞춰져(안읽음이면 false) 돌아오므로,
"안읽은 대화"를 고를 때는 이 조회를 쓰세요. 필터 없이(readState 생략) 받은 1.2.1 이하의 isRead는
마지막 메시지 기준이라, 마지막이 내 발신인 대화방은 안읽은 수신이 있어도 true로 보일 수 있습니다.
limit은 검색(search)에도 적용되며, 더 있으면 hasMore와 다음 커서(nextBefore/nextBeforeId)가 옵니다.
limit 없이 검색하면 한 번에 최대 200개를 돌려주며, 더 있으면 hasMore와 커서로 이어받을 수 있습니다.
메시지 목록(messages)은 오래된 것부터 시간순입니다.
messageType: 1=수신, 그 외=발신. timestamp는
epoch 밀리초입니다. 더 과거 페이지가 필요하면 이번 페이지 마지막 항목의 timestamp/id를
각각 before/beforeId(대화방 목록) 또는 beforeTs/beforeId(메시지 목록)로 넘기세요.
curl -H "Authorization: Bearer tp_xxxxxxxxxxxxxxxxxxxxxxxx" \
"https://twophone.co.kr/api/conversations?limit=20&readState=unread"
curl -H "Authorization: Bearer tp_xxxxxxxxxxxxxxxxxxxxxxxx" \
"https://twophone.co.kr/api/conversations/5/messages?limit=30"
curl -X POST -H "Authorization: Bearer tp_xxxxxxxxxxxxxxxxxxxxxxxx" \
"https://twophone.co.kr/api/conversations/5/read"
| 상태 코드 | 본문 | 의미 |
|---|---|---|
| 401 | - | API 키가 없거나 잘못됨 |
| 402 | {"ok":false,"error":"premium_required"} | 구독이 필요한 계정 |
| 429 | {"ok":false,"error":"rate_limited","retryAfterSeconds":18, | 조회 한도 초과(등급별 — 6항 표). 발송 거절 429 와 같은 모양이라 같은 파서로 읽으면 됩니다. limitKind:"read"가 조회 예산임을 밝힙니다(발송 한도와는 별개의 예산). Retry-After 헤더에 같은 값이 실립니다 |
| 501 | {"ok":false,"error":"feature_unavailable","feature":"contact_search"} | 연결된 휴대폰 앱이 그 기능을 모름(11~13항). 앱 업데이트가 필요하며, 설정을 켜도 해결되지 않습니다 |
| 502 | {"ok":false,"error":"agent_rejected","detail":"..."} | 휴대폰이 요청을 거절함(주로 "AI가 대화 내용을 읽도록 허용" 설정이 꺼져 있는 경우). detail에 휴대폰이 준 사유 문장이 그대로 실립니다. 사용자가 앱에서 스위치를 켜면 풀립니다 — 501과 헷갈리지 마세요 |
| 503 | {"ok":false,"error":"device_offline"} | 휴대폰이 꺼져 있거나 절전 상태 |
| 504 | {"ok":false,"error":"timeout"} | 제한 시간(10초) 안에 휴대폰이 응답하지 않음. 휴대폰이 연결돼 있어도 대화가 많은 휴대폰에서 검색(search)하면 늦을 수 있습니다(특히 앱 1.2.1 이하). 잠시 후 다시 시도하세요 |
GET /api/conversations와
GET /api/conversations/{threadId}/messages는 아무리 자주 호출해도 안읽음 표시를
바꾸지 않습니다. 읽음 처리는 오직 POST /api/conversations/{threadId}/read를
명시적으로 호출했을 때만 일어납니다.새 문자가 도착했을 때 폴링 없이 알림을 받을 수 있는 두 가지 방법입니다. 둘 다 휴대폰 앱의 설정 > AI 연동에서 "새 메시지 도착을 AI 연동 시스템에 알림"이 켜져 있어야 실제로 이벤트가 나갑니다(꺼져 있으면 구독·등록은 되지만 아무 이벤트도 오지 않습니다). 문자 본문은 "알림에 메시지 내용 미리보기 포함"이 함께 켜져 있을 때만 실립니다.
message_new는 실제로 받은 문자에만 옵니다. 내가
/api/send나 MCP로 보낸 문자, 사용자가 휴대폰에서 보낸 문자는 이벤트를 만들지 않습니다 —
자동 답장을 붙였을 때 자기가 보낸 문자에 자기가 답장하는 무한 루프(그대로 요금이 됩니다)를
원천적으로 막기 위한 설계입니다. 그러니 수신 처리 쪽에서 "내가 보낸 것인지" 따로 거를 필요가 없습니다.curl -N -H "Authorization: Bearer tp_xxxxxxxxxxxxxxxxxxxxxxxx" \
https://twophone.co.kr/api/events
event: message_new
data: {"threadId":5,"address":"01011112222","timestamp":1757300000000,"hasAttachment":false,"lineId":"PHYSICAL_MAIN","preview":"내일 뵙겠습니다"}
약 25초마다 : ping 주석 줄이 하트비트로 옵니다(이벤트 아님, 연결
유지용). 계정당 동시 스트림은 4개까지이며, 넘으면 503 too_many_streams입니다.
이 스트림은 조회 한도(9항)를 쓰지 않습니다 — 폴링 대신 이걸 쓰라는 뜻입니다.
PUT으로 URL을 등록하면(https://만 허용, 내부망·사설 IP·localhost로
풀리는 주소는 등록 자체가 거부됩니다) 새 문자마다 그 URL로 아래 형태를 POST합니다.
PUT https://twophone.co.kr/api/webhook
Authorization: Bearer tp_xxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
{ "url": "https://내서버.example.com/twophone-hook" }
POST https://내서버.example.com/twophone-hook
Content-Type: application/json
X-TwoPhone-Signature: sha256=<hex>
{ "event": "message_new", "data": { "threadId": 5, "address": "01011112222", ... },
"limits": { "minute": {"used":2,"limit":10,"remaining":8,"resetInSeconds":31,"resetAt":"…","exhausted":false},
"hour": {…}, "day": {…} },
"limitsScope": "server" }
GET /api/usage를 한 번 더 부르지 않아도 되도록, /api/send 응답과
같은 모양의 limits·limitsScope를 싣습니다(4항).
숫자는 중개 서버의 계정 단위 한도이며("server"), 실제 발송은 휴대폰의 회선 한도에서
추가로 막힐 수 있습니다.event·data 뒤에 덧붙은 것이고 기존 필드의 이름·위치·타입은
그대로입니다 — 모르는 필드를 무시하는 수신처는 아무것도 바꾸지 않아도 됩니다. 다만
서명은 본문 전체에 대해 계산되므로(아래 "웹훅 서명 검증"), 서명 검증은 반드시
받은 원본 바이트 그대로 하세요 — 필드를 골라내 다시 직렬화한 값으로는 맞지 않습니다.실패하면 1회만 재시도하고 포기합니다(이벤트는 "지금 벌어지는 일"을 알리는 용도라 오래된 재시도는
의미가 없습니다). GET /api/webhook으로 등록된 URL을 확인하고, DELETE로
해제합니다.
| 등록 규칙 | 내용 |
|---|---|
https://만 | http://는 400 invalid_webhook_url(detail에 한국어 사유). 문자 내용이 지나가는 통로라 평문을 허용하지 않습니다 |
| 내부 주소 금지 | localhost, 사설망(10/8·172.16/12·192.168/16), 루프백, 링크로컬(169.254.0.0/16 — 클라우드 메타데이터 포함), CGNAT·예약 대역으로 풀리는 호스트는 등록이 거부됩니다. 이름이 해석되지 않아도 거부됩니다 |
| 보낼 때 한 번 더 | 등록 때 공인 IP였던 도메인이 나중에 내부 주소로 바뀌어도(DNS 리바인딩) 연결 직전에 실제 IP를 다시 검사해 차단합니다 |
| 리다이렉트 없음 | 3xx 응답을 따라가지 않습니다(그대로 실패로 처리). 수신 URL은 최종 목적지여야 합니다 — 리다이렉트로 연결해 둔 주소는 조용히 아무것도 받지 못합니다 |
| 응답 시간 | 왕복 10초. 오래 걸리는 처리는 즉시 200을 돌려주고 뒤에서 처리하세요 |
| 동시 전송·대기 | 구독 등급별입니다(6항 표 — Plus 4/64 · Pro 8/128 · Business 16/256). 자리가 차면 이벤트를 버리지 않고 최대 30초 대기 줄에 세우고, 줄까지 차야 버립니다. 수신처가 느릴수록 줄이 빨리 차므로 즉시 200을 돌려주는 것이 유실을 막는 가장 확실한 방법입니다 |
| 자동 중단 | 연속 20회 실패하면 10분간 전송을 멈추고, 그 뒤 1건만 보내 살아났는지 확인합니다(성공하면 즉시 정상 복귀). 등급과 무관합니다 — 자원 배분이 아니라 죽은 URL을 계속 두드리지 않기 위한 장치입니다 |
X-TwoPhone-Signature는 발송 API 키를 비밀키로 쓴 HMAC-SHA256입니다
(요청 본문 전체에 대해 계산). 다른 사람이 TwoPhone인 척 여러분의 서버에 요청을 보내는 것을 막으려면
반드시 검증하세요.
// Node.js 예시
const crypto = require('crypto');
function verifyTwoPhoneWebhook(rawBody, signatureHeader, apiKey) {
const expected = 'sha256=' + crypto
.createHmac('sha256', apiKey)
.update(rawBody) // 파싱 전 원본 바이트여야 합니다
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected),
);
}
express.raw() 등). 비교는 타이밍 공격을 피하기 위해 상수 시간 비교
(crypto.timingSafeEqual)를 쓰세요.POST /api/send가 돌려준 messageId가 지금 어떤 상태인지 확인합니다.
응답에 문자 내용과 상대 번호가 들어가지 않으므로 AI 연동 스위치와 무관하게 동작합니다
(호출자는 자기가 보낸 문자의 id로 대조하면 되므로 번호가 필요 없습니다).
GET /api/messages/status?ids=1840,1841,9999
Authorization: Bearer tp_xxxxxxxxxxxxxxxxxxxxxxxx
200 {"statuses":[
{"messageId":1840,"status":"delivered","sentAt":1789000000000,"deliveredAt":1789000004000,
"lineId":"ESIM_MAIN","errorCode":0},
{"messageId":1841,"status":"sent","sentAt":1789000010000,"lineId":"ESIM_MAIN","errorCode":0},
{"messageId":1843,"status":"failed","sentAt":1789000020000,"lineId":"ESIM_MAIN",
"errorCode":4,"reason":"RESULT_ERROR_NO_SERVICE"},
{"messageId":9999,"status":"unknown"}]}
| status | 뜻 |
|---|---|
queued | 휴대폰이 발송을 접수했고 아직 나가지 않음 |
sent | 발송됨. 전달확인 리포트는 오지 않았음 — 실패가 아닙니다 |
delivered | 상대 단말 전달까지 확인됨 |
failed | 발송 실패. errorCode와 reason(코드 이름)이 함께 옵니다 |
unknown | 그런 id가 없거나, 내가 보낸 문자가 아님(수신·임시보관). 이 행에는 messageId·status 외에 아무 필드도 없습니다 |
sent를 실패로 읽지 마세요. 전달확인(delivered)은
SMS 전달확인 리포트가 올 때만 붙습니다. 통신사·요금제에 따라 리포트가 아예 오지 않으면 정상적으로
발송된 문자가 영원히 sent에 머물고, MMS는 전달확인을 쓰지 않습니다.
sent를 보고 다시 보내면 상대는 같은 문자를 두 번 받고 요금도 두 번 나갑니다.2계층 결과 코드 — 국내 문자 API의 공통 구조와 같습니다.
| 계층 | 어디서 | 값 |
|---|---|---|
| ① 접수 | POST /api/send의 error | invalid_address·ad_label_missing·rate_limited·duplicate_send …(4항) |
| ② 전달 | 이 API의 status + errorCode/reason | sent/delivered/failed + 통신사 결과 코드 |
errorCode는 SMS와 MMS가 같은 숫자를 다른 뜻으로 씁니다
(4 = SMS RESULT_ERROR_NO_SERVICE / MMS MMS_ERROR_HTTP_FAILURE). 휴대폰이 어느 쪽인지 알고
이름(reason)을 붙여 주며, 모르는 코드에는 이름을 지어내지 않고 reason을 생략합니다.
| 규칙 | 내용 |
|---|---|
| 배치 필수 | ids는 쉼표로 구분한 양의 정수 1~50개. 공백은 허용되고 중복은 제거되며 요청 순서가 그대로 유지됩니다(인덱스로 대조할 수 있습니다). 조회 한 번은 잠든 휴대폰을 깨우는 동작이라, 건별 반복 호출은 그 자체가 사고입니다 |
| 전부 아니면 전무 | 값 하나라도 정수가 아니거나 51개 이상이면 400 invalid_ids로 요청 전체가 거절됩니다(일부만 조용히 버리지 않습니다) |
| 캐시 금지 | 응답에 Cache-Control: no-store가 붙습니다 |
| 한도 | 9항의 조회 한도(등급별 — 6항 표)를 함께 씁니다 |
| 읽음 처리 | 하지 않습니다. 읽음 상태를 읽지도 쓰지도 않습니다 |
지정한 시각에 휴대폰이 문자를 보내도록 예약합니다. 발송 전까지 취소할 수 있습니다.
POST /api/schedules
Authorization: Bearer tp_xxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
{ "address": "01012345678",
"body": "(광고) 주말 20% 할인 … 무료수신거부 080-000-0000",
"lineId": "ESIM_MAIN",
"sendAt": "2026-09-19T09:00:00+09:00",
"advertisement": true,
"idempotencyKey": "job-4821" }
201 { "ok": true, "scheduleId": 77, "address": "01012345678",
"lineId": "ESIM_MAIN", "sendAt": 1789099999999 }
lineId를 생략하면 등록 시점에 휴대폰의 기본 발신 회선(1항 표 참고)으로 확정해 저장하므로,
예약 목록과 휴대폰 대화방 배너가 등록 직후부터 어느 번호로 나갈지 보여 줍니다.
sendAt은 epoch 밀리초 숫자와 ISO-8601 문자열을 모두 받습니다.
시간대를 적지 않은 문자열(2026-09-19 09:00)은 한국 시간으로 읽습니다.
to·line·is_advertisement·send_at도 같은 뜻의 별칭으로 받습니다.
| 엔드포인트 | 설명 |
|---|---|
POST /api/schedules | 예약 등록. 성공 201, 멱등 재응답은 200 + Idempotency-Replay: true |
GET /api/schedules | 대기·실패 예약 목록(예정 시각 순). 본문이 나가므로 "AI가 대화 내용을 읽도록 허용"이 켜져 있어야 합니다 — 꺼져 있으면 502 agent_rejected |
DELETE /api/schedules/{id} | 예약 취소. 없는 id를 지워도 200 ok입니다(멱등) — 정말 있었는지 확인하려면 목록을 먼저 보세요 |
POST /api/schedules/{id}/send-now | 예약을 지금 발송(휴대폰이 꺼져 있어 놓친 예약을 되살리는 용도). 21~08시에도 거절하지 않고 발송하며, 이 API로 광고로 선언해 등록한 예약이면 그 사실을 반영한 ad_night_advisory 고지를, 광고인지 모르는 예약(휴대폰 앱·PC 웹에서 만들었거나 서버 재시작 뒤)이면 단정하지 않는 ad_night_suspected 고지를 notice로 싣습니다. 없는 id는 502 agent_rejected |
GET /api/schedules
200 {"schedules":[
{"scheduleId":77,"address":"01012345678","body":"내일 9시에 뵙겠습니다",
"lineId":"ESIM_MAIN","sendAt":1789099999999,"status":"pending"},
{"scheduleId":74,"address":"01033334444","body":"확인 부탁드립니다",
"lineId":"PHYSICAL_MAIN","sendAt":1789000000000,"status":"failed",
"failureReason":"too_late","failureDetail":"예정 시각을 6시간 넘겨 …","failedAt":1789000600000,
"attachmentCount":1}],
"count":2}
휴대폰 앱·PC 웹에서 사용자가 직접 만든 예약도 함께 나옵니다.
failureReason: interrupted(보내다 중단) · too_late(예정 시각 6시간 초과) ·
send_failed. 앞으로 값이 늘어날 수 있으니 모르는 값도 처리하세요(failureDetail에 설명 문장이 함께 옵니다).
첨부는 개수만 나갑니다 — 휴대폰 내부 파일 경로는 노출하지 않습니다.
| 규칙 | 오류 |
|---|---|
| 번호·본문·회선·길이(2000자) | 400 invalid_address / empty_body / body_too_long / unknown_line |
sendAt이 지금+60초 이후, 90일 이내 | 400 invalid_send_at |
advertisement: true인데 본문이 (광고)로 시작하지 않음 | 400 ad_label_missing |
광고인데 sendAt이 KST 08:00~20:30 밖 | 오류가 아닙니다 — 201로 등록되고 noticeCodes에 ad_night_advisory(21~08시) 또는 ad_schedule_late_advisory(20:30~21:00)가 실립니다 |
| 대기 예약이 계정당 50건을 넘음 | 429 too_many_schedules |
| 같은 번호+본문+시각+회선이 이미 대기 중(멱등 키 없이 호출한 경우) | 409 duplicate_schedule |
같은 idempotencyKey로 다른 내용 | 409 idempotency_key_reuse |
| 발송 한도 초과 | 429 rate_limited |
limits·limitsScope가 /api/send와 같은 모양으로 실립니다(4항).messageId가 됐는지 알 수 없습니다 — 즉 11항으로 성공을 추적할 수 없습니다.
반대로 나가지 못한 예약은 status:"failed"와 failureReason으로 목록에 남으므로
실패 감지는 됩니다. 목록에서 사라진 것을 "취소됐다"로 해석하지 마세요.대기 예약 개수는 이 API로 등록한 예약을 기준으로 셉니다(발송 한도가 API 발송만 세는 것과 같습니다).
휴대폰 앱·PC 웹에서 직접 만든 예약은 GET /api/schedules에는 보이지만 이 상한 계산에는 들어가지 않고,
서버를 재시작하면 카운터가 초기화됩니다 — 이 값은 회계 장부가 아니라 폭주를 끊는 안전장치입니다.
저장된 연락처를 이름·메모·태그로 찾아 이름과 전화번호, 기본 발신 회선만 돌려줍니다. 휴대폰 앱의 설정 > AI 연동에서 "AI가 연락처를 검색하도록 허용"(기본 꺼짐)을 켜야 동작하며, "AI가 대화 내용을 읽도록 허용"을 켜도 이 기능은 열리지 않습니다 — 주소록은 대화와 다른 데이터 범주라 별도 동의입니다.
GET /api/contacts/search?q=김사장&limit=5
Authorization: Bearer tp_xxxxxxxxxxxxxxxxxxxxxxxx
200 {"contacts":[
{"displayName":"김사장",
"numbers":["01012345678","0212345678"],"defaultLineId":"ESIM_MAIN"}],
"hasMore":false}
| 규칙 | 내용 |
|---|---|
q 필수 | 1자 이상. 없으면 400 missing_query — 전체 주소록 덤프는 불가능합니다 |
limit | 기본 5, 최대 10(초과 값은 10으로 깎습니다). 정수가 아니면 400 invalid_limit |
| 나가는 값 | 이름·모든 전화번호·기본 발신 회선뿐입니다. 메모·태그는 찾는 데만 쓰고 내보내지 않으며, 이메일·주소·소속·사진도 나가지 않습니다 |
hasMore | true면 limit을 넘는 일치가 더 있습니다(개수는 세지 않습니다). 검색어를 좁히세요 |
| 빈 결과 | "연락처가 없다"로 단정하지 마세요 — 휴대폰 앱에 연락처 권한이 없어도 똑같이 빈 결과가 옵니다(두 경우를 구분할 수 없습니다) |
| 한도 | 9항의 조회 한도(등급별 — 6항 표)를 함께 씁니다. 응답에 Cache-Control: no-store |
연락처를 추가·수정·삭제할 수는 없습니다. 이 API로 열린 것은 읽기 하나뿐입니다.
응답에 연락처 식별자(안드로이드 lookupKey)는 들어 있지 않습니다 —
기기 안에서만 뜻이 있는 값이고 이 API의 어떤 요청에서도 받지 않으며, 연락처가 갱신되면 값이 바뀔 수 있어
저장해 두어도 안정적인 키가 되지 못합니다. 같은 사람을 다시 가리켜야 한다면 전화번호를 쓰세요
(REST와 MCP search_contacts가 같은 필드 집합을 냅니다).