> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.claw-ops.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.claw-ops.com/_mcp/server.

# 문자 메시지

> 하나의 엔드포인트로 SMS·LMS·MMS를 발송합니다. 본문 길이와 첨부에 따른 타입 선택, 이미지 첨부 제약, 발송 결과와 수신 문자 webhook을 안내합니다.

ClawOps는 하나의 엔드포인트로 SMS·LMS·MMS를 모두 발송합니다. 본문 길이와 첨부 유무에 맞는 `Type`만 지정하면 통신사까지의 전달은 플랫폼이 처리합니다.

```text
POST /v1/accounts/{accountId}/messages
```

성공하면 `201`과 함께 접수된 메시지가 돌아옵니다. 통신사 리포트는 그보다 늦게 도착하므로 최종 발송 결과는 webhook으로 받습니다.

지원 채널은 SMS·LMS·MMS 세 가지입니다. iMessage, RCS와 카카오 알림톡은 발송할 수 없습니다.

## 채널

|                | SMS      | LMS    | MMS    |
| -------------- | -------- | ------ | ------ |
| 본문 한도          | 200 byte | 2,000자 | 2,000자 |
| 제목 (`Subject`) | 불가       | 가능     | 가능     |
| 이미지 첨부         | 불가       | 불가     | 최대 3장  |

한도의 기준이 서로 다릅니다. SMS는 UTF-8 바이트로, LMS와 MMS는 글자 수로 셉니다. 한글은 한 자에 3바이트이므로 한글로만 작성하면 SMS에 66자까지 들어가고, 영문과 숫자를 섞으면 더 많이 들어갑니다.

본문이 200byte를 넘길지 확실하지 않다면 처음부터 `Type`을 `lms`로 지정하세요. `sms`로 보냈다가 한도를 넘기면 발송되지 않고 `400`이 반환됩니다. 본문에 이름 같은 변수를 넣는 경우 이름이 긴 수신자에서만 실패하므로 뒤늦게 발견됩니다.

## 메시지 보내기

| 필드         | 타입        | 설명                                                     |
| ---------- | --------- | ------------------------------------------------------ |
| `To`       | string    | **필수.** 수신번호입니다. 국내 표기와 `+82` E.164를 모두 받아 정규화합니다.     |
| `From`     | string    | **필수.** 발신번호입니다. 계정에 등록된 번호만 사용할 수 있습니다.               |
| `Body`     | string    | **필수.** 본문입니다. SMS는 200byte, LMS와 MMS는 2,000자까지 허용합니다. |
| `Type`     | string    | `sms`, `lms`, `mms` 중 하나입니다. 기본값은 `sms`입니다.            |
| `Subject`  | string    | LMS와 MMS의 제목입니다. SMS에는 사용할 수 없습니다.                     |
| `MediaUrl` | string\[] | MMS 첨부 이미지 URL입니다. 최대 3개까지 허용합니다.                      |

```bash title="cURL"
curl -X POST "https://api.claw-ops.com/v1/accounts/YOUR_ACCOUNT_ID/messages" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "To": "01012345678",
    "From": "07012341234",
    "Body": "주문하신 상품이 오늘 출고되었습니다."
  }'
```

```python title="Python"
from clawops import ClawOps

client = ClawOps(
    api_key="YOUR_API_KEY",
    account_id="YOUR_ACCOUNT_ID",
)

message = client.messages.create(
    to="01012345678",
    from_="07012341234",
    body="주문하신 상품이 오늘 출고되었습니다.",
)

print(message.message_id, message.status)
```

```typescript title="TypeScript"
import ClawOps from "@teamlearners/clawops";

const client = new ClawOps({
  apiKey: process.env.CLAWOPS_API_KEY,
  accountId: process.env.CLAWOPS_ACCOUNT_ID,
});

const message = await client.messages.create({
  to: "01012345678",
  from: "07012341234",
  body: "주문하신 상품이 오늘 출고되었습니다.",
});

console.log(message.messageId, message.status);
```

```json title="응답 (201)"
{
  "messageId": "MG3f7a1c9e2b8d4f0a6c5e1b9d7a3f2c48",
  "status": "queued",
  "type": "sms",
  "to": "01012345678",
  "from": "07012341234",
  "body": "주문하신 상품이 오늘 출고되었습니다.",
  "subject": null,
  "numMedia": 0,
  "mediaUrl": [],
  "accountId": "YOUR_ACCOUNT_ID",
  "direction": "outbound",
  "dateCreated": "2026-08-10T04:12:44.123Z"
}
```

Python은 예약어를 피해 `from_`을 사용합니다. 두 SDK 모두 메서드 이름은 `create`입니다.

`status`의 `queued`는 발송 요청이 접수됐다는 뜻이며 전달 성공을 보장하지 않습니다. 이 시점에는 통신사 리포트가 돌아오기 전이라 성공과 실패를 알 수 없습니다. 최종 결과는 webhook으로 확인하세요.

### 수신번호

`To`는 국내 표기와 `+82` E.164를 모두 받습니다. 공백, 하이픈, 괄호, 점은 제거되므로 `010-1234-5678`과 `01012345678`은 같은 값입니다. 저장과 조회는 정규화된 형태로 이뤄집니다.

| 입력                                                 | 결과                       |
| -------------------------------------------------- | ------------------------ |
| `010-1234-5678` · `+82 10-1234-5678`               | `01012345678`            |
| `02-123-4567` · `070-1234-5678` · `0507-1234-5678` | 그대로(구분자만 제거)             |
| `1588-1234`                                        | `15881234` (대표번호 8자리)    |
| `+1 415 555 0100`                                  | `400` — 국제 발신은 지원하지 않습니다 |
| `1012345678` (앞 `0` 없음)                            | `400` — 형식 오류            |

### 발신번호

`From`은 필수이며 계정이 보유한 번호여야 합니다. 보유하지 않은 번호를 지정하면 `400`이 반환됩니다. 발신번호를 자동으로 고르는 동작은 없습니다. 수신번호와 달리 `From`은 하이픈만 제거하고 숫자 3\~12자리인지만 확인합니다.

### 이미지 첨부

`Type`을 `mms`로 지정하고 `MediaUrl`에 이미지 URL을 전달합니다.

```bash
curl -X POST "https://api.claw-ops.com/v1/accounts/YOUR_ACCOUNT_ID/messages" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "To": "01012345678",
    "From": "07012341234",
    "Type": "mms",
    "Subject": "촬영본 안내",
    "Body": "요청하신 사진 보내 드립니다.",
    "MediaUrl": [
      "https://example.com/room-1.jpg",
      "https://example.com/room-2.jpg"
    ]
  }'
```

| 항목        | 제한                                        |
| --------- | ----------------------------------------- |
| 첨부 장수     | 최대 3장                                     |
| 형식        | `jpg`, `jpeg`, `png`, `bmp`               |
| 장당 크기     | 300KB (307,200 byte). 변환 전 내려받은 원본 기준입니다. |
| 다운로드 제한시간 | 10초                                       |

URL은 인증 없이 접근할 수 있는 주소여야 합니다. 플랫폼이 직접 내려받아 통신사로 전달하므로 인증이 필요한 주소나 내부망 주소는 거절됩니다.

형식은 두 단계로 검사합니다. URL 경로의 확장자가 위 목록에 있어야 하고, 내려받은 응답의 `Content-Type`도 `image/jpeg`, `image/png`, `image/bmp` 중 하나여야 합니다. 확장자가 드러나지 않는 URL은 내용과 무관하게 거절되므로 `.jpg`처럼 확장자가 붙은 주소를 사용하세요.

PNG와 BMP는 서버가 JPG로 변환해 발송합니다. 통신사가 MMS에서 PNG와 BMP를 거부하기 때문입니다. 변환 과정에서 투명 배경은 흰색으로 합성되고 EXIF 회전 정보는 픽셀에 반영됩니다. 원본이 이미 JPEG이면 재인코딩하지 않고 그대로 씁니다.

첨부 없이 `Type`을 `mms`로 지정하면 전달 자체는 LMS와 같아지지만, **한도와 과금은 MMS로 잡힙니다.** 첨부가 없다면 처음부터 `lms`를 사용하세요.

## 조회

발송과 수신 이력을 한 목록에서 조회합니다. 응답의 `direction`으로 구분하며 정렬은 최신순 고정입니다.

```text
GET /v1/accounts/{accountId}/messages
```

| 쿼리         | 타입      | 설명                                             |
| ---------- | ------- | ---------------------------------------------- |
| `type`     | string  | `sms`, `lms`, `mms`로 필터합니다.                    |
| `status`   | string  | `queued`, `sent`, `failed`, `received`로 필터합니다. |
| `number`   | string  | 발신·수신 양쪽에서 매칭합니다. 하이픈 유무를 모두 흡수합니다.            |
| `page`     | integer | 페이지 번호입니다. 기본값은 `0`이며 0부터 시작합니다.               |
| `pageSize` | integer | 페이지당 건수입니다. 기본값은 `20`입니다. `100`을 넘기면 `400`입니다. |

```bash title="cURL"
curl "https://api.claw-ops.com/v1/accounts/YOUR_ACCOUNT_ID/messages?type=sms&pageSize=20" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

```python title="Python"
page = client.messages.list(type="sms", status="sent", page=0, page_size=20)

for message in page:
    print(message.message_id, message.status)

# 전체를 자동으로 순회합니다
for message in client.messages.list().auto_paging_iter():
    print(message.message_id)
```

```typescript title="TypeScript"
const page = await client.messages.list({
  type: "sms",
  status: "sent",
  page: 0,
  pageSize: 20,
});

for (const message of page.data) {
  console.log(message.messageId, message.status);
}
```

```json title="응답"
{
  "data": [
    {
      "messageId": "MG3f7a1c9e2b8d4f0a6c5e1b9d7a3f2c48",
      "accountId": "YOUR_ACCOUNT_ID",
      "direction": "outbound",
      "type": "sms",
      "from": "07012341234",
      "to": "01012345678",
      "subject": null,
      "body": "주문하신 상품이 오늘 출고되었습니다.",
      "status": "sent",
      "numMedia": 0,
      "mediaUrl": [],
      "dateCreated": "2026-08-10T04:12:44.123Z",
      "dateUpdated": "2026-08-10T04:12:51.884Z"
    }
  ],
  "meta": { "page": 0, "pageSize": 20, "total": 1 }
}
```

단건 조회와 첨부 원본 조회는 다음 두 엔드포인트를 사용합니다.

```text
GET /v1/accounts/{accountId}/messages/{messageId}
GET /v1/accounts/{accountId}/messages/{messageId}/media/{index}
```

`index`는 0부터 시작합니다. 목록·단건 응답의 `mediaUrl`과 수신 webhook의 `MediaUrl{i}`는 모두 이 엔드포인트를 가리키는 절대 URL입니다 — 저장소 주소가 아닙니다.

```json
"mediaUrl": [
  "https://api.claw-ops.com/v1/accounts/AC1a2b3c4d/messages/MG3f7a…/media/0",
  "https://api.claw-ops.com/v1/accounts/AC1a2b3c4d/messages/MG3f7a…/media/1"
]
```

이 URL도 API 키 인증이 필요합니다. 첨부는 전부 JPEG으로 변환된 뒤 저장되므로 `image/jpeg`로 내려옵니다.

대화 스레드 API는 제공하지 않습니다. 상대 번호별로 주고받은 이력을 묶으려면 `number`로 필터하거나 목록 응답을 `from`과 `to` 기준으로 직접 그룹핑하세요.

## 발송 결과와 수신 문자

발송 결과와 수신 문자는 모두 계정에 등록한 webhook으로 전달됩니다. 메시지마다 콜백 URL을 지정하는 파라미터는 없습니다.

| 이벤트                | 발생 시점                       |
| ------------------ | --------------------------- |
| `message.sent`     | 통신사로부터 발송 성공 리포트를 받은 직후입니다. |
| `message.failed`   | 발송이 실패로 종료된 직후입니다.          |
| `message.received` | 보유 번호로 문자가 도착했을 때입니다.       |

```bash
curl -X POST "https://api.claw-ops.com/v1/accounts/YOUR_ACCOUNT_ID/webhooks" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/message-webhook",
    "events": ["message.sent", "message.failed", "message.received"]
  }'
```

상태는 `queued`에서 출발해 `sent` 또는 `failed`로 끝납니다. 그 사이의 중간 상태는 없습니다. 수신 메시지는 `received`로 기록됩니다.

### 페이로드

모든 요청은 `Content-Type: application/x-www-form-urlencoded` POST입니다.

| 파라미터                       | 이벤트                | 설명                                    |
| -------------------------- | ------------------ | ------------------------------------- |
| `MessageId`                | 공통                 | 메시지 고유 ID입니다.                         |
| `AccountId`                | 공통                 | 계정 ID입니다.                             |
| `From`, `To`               | 공통                 | 발신번호와 수신번호입니다.                        |
| `Direction`                | 공통                 | 발송은 `outbound`, 수신은 `inbound`입니다.     |
| `Type`                     | 공통                 | `sms`, `lms`, `mms` 중 하나입니다.          |
| `Status`                   | 공통                 | `sent`, `failed`, `received` 중 하나입니다. |
| `Timestamp`                | 공통                 | 이벤트 발생 시각입니다. ISO 8601 형식입니다.         |
| `Subject`, `Body`          | `message.received` | 수신 메시지의 제목과 본문입니다. 없으면 빈 문자열입니다.      |
| `NumMedia`                 | `message.received` | 첨부 개수입니다. 없으면 `0`입니다.                 |
| `MediaUrl0`, `MediaUrl1` … | `message.received` | 첨부 저장 경로입니다. `NumMedia` 만큼 전달됩니다.     |

발송 결과 webhook에는 `Subject`·`Body`·`NumMedia`가 실리지 않습니다. 본문이 필요하면 `MessageId`로 단건 조회하세요.

```python
from flask import Flask, request

app = Flask(__name__)

@app.route("/message-webhook", methods=["POST"])
def message_webhook():
    status = request.form.get("Status")
    message_id = request.form.get("MessageId")

    if status == "received":
        print(f"수신 [{message_id}]: {request.form.get('Body', '')}")
    elif status == "failed":
        print(f"발송 실패 [{message_id}] → {request.form.get('To')}")

    return "", 204
```

`MediaUrl{i}`는 첨부 조회 엔드포인트를 가리키는 절대 URL입니다. 목록·단건 조회 응답의 `mediaUrl`과 같은 주소이며, 그대로 요청하면 원본이 내려옵니다.

이 URL도 **API 키 인증이 필요합니다.** `Authorization` 헤더 없이 요청하면 `401`입니다. 공개 저장소 주소가 아닙니다.

요청에는 `X-Signature` 헤더가 포함됩니다. 계정의 Signing Key로 HMAC-SHA256 서명을 검증한 뒤 처리하세요.

실패 사유는 전달되지 않습니다. `message.failed`는 실패했다는 사실만 알려주며 통신사 결과코드는 포함되지 않습니다.

## 제한과 함정

### 발송이 거절되는 경우

에러 응답의 형태는 어디서 막혔는지에 따라 두 가지입니다.

요청 형식 검증에서 막히면 `errors` 배열과 `code`가 함께 옵니다. 어느 필드가 왜 틀렸는지는 `errors[].path`를 보세요.

```json title="형식 검증 실패"
{
  "error": "request/body/MediaUrl must NOT have more than 3 items",
  "errors": [
    {
      "path": "/body/MediaUrl",
      "message": "must NOT have more than 3 items",
      "errorCode": "maxItems.openapi.validation"
    }
  ],
  "code": "VALIDATION"
}
```

형식을 통과한 뒤 업무 규칙에서 막히면 `errors` 배열 없이 `error`와 `code`만 옵니다.

```json title="업무 규칙 위반"
{ "error": "SMS Body는 200byte를 초과할 수 없습니다", "code": "body_too_long" }
```

**분기는 `code`로 하세요.** `error` 문구는 안내를 다듬으면서 바뀔 수 있지만 `code`는 계약입니다. 같은 `422`라도 `recipient_blocked`(명단에서 해제해야 나감)와 `quota_exceeded`(다음 주기에 다시 나감)는 대응이 정반대입니다.

후자에 해당하는 경우는 다음과 같습니다.

| 상태    | `code`                    | 메시지                                      | 원인                                                    |
| ----- | ------------------------- | ---------------------------------------- | ----------------------------------------------------- |
| `400` | `invalid_input`           | `To, From, Body는 필수입니다`                  | 필드는 있으나 빈 문자열입니다.                                     |
| `400` | `invalid_phone`           | `전화번호 형식이 올바르지 않습니다`                     | `To`가 정규화 규칙에 맞지 않습니다. 앞 `0`이 빠진 번호가 대표적입니다.          |
| `400` | `invalid_phone`           | `국제 발신은 지원하지 않습니다`                       | `To`가 `+82` 외의 국가번호입니다.                               |
| `400` | `invalid_phone`           | `From은 유효한 전화번호 형식이어야 합니다`               | 하이픈 제거 후 숫자 3\~12자리가 아닙니다.                            |
| `400` | `from_not_registered`     | `From 번호가 계정에 등록되지 않았습니다`                | 보유하지 않은 발신번호입니다.                                      |
| `400` | `body_too_long`           | `SMS Body는 200byte를 초과할 수 없습니다`          | SMS 본문 한도를 넘었습니다. `lms`로 보내세요.                        |
| `400` | `body_too_long`           | `LMS Body는 2000자를 초과할 수 없습니다`            | 본문 한도를 넘었습니다. `mms`로 보낸 경우 `MMS Body는…`로 반환됩니다.       |
| `400` | `sms_no_media`            | `SMS에는 MediaUrl을 첨부할 수 없습니다`             | 첨부는 MMS에서만 가능합니다.                                     |
| `400` | `sms_no_subject`          | `SMS에는 Subject를 설정할 수 없습니다`              | 제목은 LMS와 MMS에서만 가능합니다.                                |
| `400` | `lms_no_media`            | `LMS에는 MediaUrl을 첨부할 수 없습니다. MMS를 사용하세요` | 첨부가 있으면 `mms`로 보내세요.                                  |
| `400` | `invalid_media_ext`       | `지원하지 않는 이미지 확장자입니다: gif`                | jpg, jpeg, png, bmp 외의 확장자입니다. 확장자가 없으면 `(없음)`이 붙습니다. |
| `400` | `media_download_failed`   | `이미지 처리 실패 (0): SIZE_EXCEEDED`           | 300KB를 넘었습니다. 괄호 안 숫자는 몇 번째 첨부인지를 나타냅니다.              |
| `400` | `media_download_failed`   | `이미지 처리 실패 (0): HTTP 404`                | URL에 접근할 수 없습니다. 상태코드가 그대로 붙습니다.                      |
| `400` | `media_download_failed`   | `이미지 처리 실패 (0): CONTENT_TYPE:text/html`  | 확장자는 맞지만 응답의 `Content-Type`이 이미지가 아닙니다.               |
| `403` | `no_active_subscription`  | `활성 구독이 없습니다`                            | 구독이 없거나 만료됐습니다.                                       |
| `403` | `messaging_blocked`       | `문자 발송이 제한된 계정입니다. 고객센터로 문의해 주세요.`       | 운영상 발송이 차단된 계정입니다.                                    |
| `422` | `recipient_blocked`       | `수신거부 명단에 등록된 번호입니다. 해제 후 다시 시도하세요`      | `message` 채널 수신거부 명단에 있는 번호입니다.                       |
| `422` | `type_not_supported`      | `SMS가 지원되지 않는 플랜입니다.`                    | 해당 타입의 한도가 `0`인 플랜입니다. 타입에 따라 `LMS`, `MMS`로 바뀝니다.     |
| `422` | `quota_exceeded`          | `SMS quota exceeded (120/100)`           | 월 한도를 초과했고 초과분 부가서비스가 없습니다.                           |
| `422` | `override_quota_exceeded` | `문자 발송 한도를 초과했습니다 (120/100)`             | 운영상 걸린 계정별 총 발송 상한을 넘었습니다. 타입과 무관한 합계 기준입니다.          |

첨부 처리 실패는 모두 `이미지 처리 실패 (i): <원인>` 형태로 옵니다. 위 세 가지 외에 다운로드가 10초를 넘긴 경우도 같은 형태로 반환됩니다. 괄호 안 숫자는 `MediaUrl` 배열에서 몇 번째 첨부인지를 가리키므로, 여러 장을 보낼 때 어느 URL이 문제인지 이걸로 찾으세요.

### 월 한도

플랜마다 SMS, LMS, MMS 각각의 월 발송 건수 한도가 있습니다. 한도를 넘겨도 해당 타입의 초과분 부가서비스가 활성이면 발송이 계속되고 초과분이 과금됩니다. 부가서비스가 없으면 `422`로 거절됩니다.

집계 대상은 발신 중 실패하지 않은 건입니다. `queued` 상태도 포함되며 `failed`로 끝난 건은 빠집니다.

한도는 **매월** 초기화됩니다. 결제 주기가 아니라 월 단위입니다. 여러 달을 선납한 계정도 한도는 매달 리셋됩니다.

### 수신거부 명단

수신거부 명단에 등록된 번호로는 문자가 나가지 않습니다. 등록 즉시 발신 직전에 막히며 `422 recipient_blocked`로 거절됩니다. 배치 발신(캠페인)도 같은 관문을 지나므로 해당 대상은 자동으로 건너뜁니다.

```bash title="등록"
curl -X POST "https://api.claw-ops.com/v1/accounts/YOUR_ACCOUNT_ID/blocked-recipients" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "number": "01012345678", "channel": "message" }'
```

해제는 `DELETE /v1/accounts/{accountId}/blocked-recipients/{blockId}`입니다.

차단된 발송은 **메시지 이력에 남지 않습니다.** 발송이 일어나지 않았으므로 기록할 것이 없기 때문입니다. 목록 조회에서 찾지 못했다고 다시 보내지 마세요 — 명단에서 해제해야 나갑니다.

등록은 멱등입니다. 이미 차단 중인 (번호, 채널) 조합을 다시 등록하면 에러 없이 기존 항목을 `200`으로 돌려줍니다. 새로 등록된 경우에만 `201`입니다.

대상은 이 계정의 **발신**입니다. 그 번호에서 걸려오거나 도착하는 것은 막지 않습니다.

채널 값은 `call`과 `message`입니다. `sms`가 아닙니다. 전화와 문자를 모두 차단하려면 두 채널로 각각 등록해야 합니다.

### 광고성 문자

광고성 문자에는 `(광고)` 표기와 무료 수신거부 방법 안내 등 법적 의무가 따릅니다. 야간 발송 제한(21시\~익일 8시)도 적용됩니다.

야간 발송 제한은 **플랫폼이 막아주지 않습니다.** 배치 발신(전화)에는 시간대 게이트가 걸려 있지만 문자 발송 경로에는 없습니다. 밤에 요청하면 밤에 나갑니다. 발송 시각은 직접 통제하세요.