빠른 시작
- 콘솔에서 계정을 생성하고 잔액을 충전하세요.
- API 키를 열고 키를 생성하세요. 첫 충전이 완료된 후에 키를 만들 수 있어요. 키는 한 번만 표시되니 비밀번호처럼 안전하게 보관하세요.
/sms에 번호와 텍스트를 포함한 POST 요청을 보내세요. 반환된 캠페인 ID를 확인한 후 콘솔이나 웹훅을 통해 전송 상태를 확인하세요.
인증
공개 요금표를 제외한 모든 요청에는 Authorization 헤더에 API 키를 넣어요. 키는 sbk_live_로 시작해요. 새 키를 만들면 이전 키는 바로 대체돼요.
Authorization: Bearer sbk_live_YOUR_KEYSMS 보내기
POST/sms
캠페인을 생성하고 잔액에서 요금을 차감한 후 모든 메시지를 큐에 넣어요. 번호는 정리되고 중복 제거가 되며, 사용할 수 없는 번호는 dropped에 이유와 함께 반환되고 요금이 청구되지 않아요.
| 필드 | 타입 | 설명 |
|---|---|---|
to필수 | 문자열 또는 배열 | 국제 형식의 하나의 번호 또는 최대 10,000개의 번호 배열, 예: "+447700900461" |
text필수 | 문자열 | 메시지 텍스트, 최대 10 세그먼트. GSM-7 텍스트는 세그먼트당 160자, UCS-2는 70자까지 가능 |
schedule_at선택 | 문자열 | ISO 8601 형식의 시간, 5분에서 60일 이후. 생략하면 즉시 전송 |
client_ref선택 | 문자열 | 자신의 참조, 최대 64자의 a-z A-Z 0-9 _ . : -, 보고서 및 웹훅에 반환됨 |
opt_out선택 | 불리언 | true로 설정하면 계정 설정에 있는 수신 거부 안내 문구가 추가돼요 |
curl -X POST https://sendbulksms.cc/api/v1/sms \
-H "Authorization: Bearer sbk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": ["+447700900461", "+5511955550123"],
"text": "Hi, your order 4821 ships today.",
"client_ref": "orders-4821"
}'const res = await fetch("https://sendbulksms.cc/api/v1/sms", {
method: "POST",
headers: { Authorization: "Bearer " + process.env.SENDBULKSMS_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ to: ["+447700900461", "+5511955550123"], text: "Hi, your order 4821 ships today.", client_ref: "orders-4821" }),
});
const campaign = await res.json();
console.log(res.status, campaign.id, campaign.cost);import os, requests
r = requests.post(
"https://sendbulksms.cc/api/v1/sms",
headers={"Authorization": "Bearer " + os.environ["SENDBULKSMS_KEY"]},
json={"to": ["+447700900461", "+5511955550123"], "text": "Hi, your order 4821 ships today.", "client_ref": "orders-4821"},
timeout=30,
)
print(r.status_code, r.json()["id"])<?php
$ch = curl_init("https://sendbulksms.cc/api/v1/sms");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SENDBULKSMS_KEY"), "Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["to" => ["+447700900461", "+5511955550123"], "text" => "Hi, your order 4821 ships today.", "client_ref" => "orders-4821"]),
]);
$campaign = json_decode(curl_exec($ch), true);
echo $campaign["id"];응답: 캠페인과 비용 및 처음 1,000개의 메시지 캠페인 메시지로 더 많은 정보를 가져오세요.
HTTP/1.1 201 Created
{
"id": "CP-7KQ2-M9XA-4HTD",
"status": "sending",
"source": "api",
"recipients": 2,
"segments": 1,
"encoding": "GSM-7",
"cost": "0.01078",
"refunded": "0.00000",
"client_ref": "orders-4821",
"scheduled_at": null,
"created_at": "2026-11-07T10:32:04.118Z",
"done_at": null,
"counts": { "queued": 2, "sent": 0, "delivered": 0, "failed": 0, "canceled": 0 },
"dropped": [],
"balance": "248.30422",
"messages": [
{ "id": "SM-2RBW-8MZN-QF6K", "campaign_id": "CP-7KQ2-M9XA-4HTD", "to": "+447700900461", "country": "GB", "parts": 1, "price": "0.00819", "status": "queued", "error": null, "created_at": "2026-11-07T10:32:04.118Z", "sent_at": null, "done_at": null }
]
}전송 예상
POST/estimate
/sms와 동일한 본문을 받아서 실제로 요금을 청구하거나 발송하지 않고 수신자, 세그먼트, 인코딩, 총 비용 및 국가별 비용을 반환해요.
{ "recipients": 2, "dropped": [], "segments": 1, "encoding": "GSM-7", "cost": "0.01078", "balance": "248.31500",
"countries": [ { "country": "GB", "recipients": 1, "cost": "0.00819" }, { "country": "BR", "recipients": 1, "cost": "0.00259" } ] }메시지 상태
GET/sms/{message_id}
메시지 하나의 정보를 반환해요: 번호, 국가, 세그먼트 수, 가격, 상태, 실패 시 오류 내용, 그리고 각 단계의 시간이에요.
캠페인
GET/campaigns/{campaign_id}
캠페인의 비용, 환불 및 각 상태별 메시지 수를 확인할 수 있어요.
GET/campaigns/{campaign_id}/messages?offset=0&limit=100
큐에 들어간 순서대로 최대 1,000개의 메시지
POST/campaigns/{campaign_id}/cancel
예약된 캠페인이 아직 시작되지 않았다면 취소하고 전체 비용을 잔액으로 환불해요.
잔액 및 요금
GET/balance
{ "balance": "248.31500", "currency": "USD" }GET/rates
공개 API로 키가 필요하지 않아요. 모든 국가의 국가 코드와 세그먼트당 가격을 확인할 수 있어요. /rates/GB를 호출하면 특정 국가의 정보를 반환해요.
{ "updated": "2026-11-07", "rates": [ { "country": "GB", "name": "United Kingdom", "calling_code": "+44", "price": "0.00819", "currency": "USD", "unit": "SMS segment" } ] }전송 웹훅
콘솔의 API 키 메뉴에서 웹훅 URL을 설정해요. 메시지가 최종 상태(전송 완료 또는 실패)에 도달하면 리포트를 해당 URL로 POST로 보내줘요. 8초 이내에 2xx 상태로 응답하면 돼요. 그렇지 않으면 약 11시간 동안 최대 8번 재시도해요. 이미 처리한 리포트는 id를 사용해서 무시할 수 있어요.
POST https://your-server.example/sms-status
Content-Type: application/json
X-SendBulkSMS-Signature: t=1794047524,v1=5f0c3b9e...
{
"event": "message.status",
"id": "evt_8c1d2e4f6a7b9c0d1e2f",
"created_at": "2026-11-07T10:32:09.004Z",
"data": {
"message_id": "SM-2RBW-8MZN-QF6K",
"campaign_id": "CP-7KQ2-M9XA-4HTD",
"to": "+447700900461",
"country": "GB",
"status": "delivered",
"error": null,
"parts": 1,
"price": "0.00819",
"client_ref": "orders-4821",
"done_at": "2026-11-07T10:32:08.912Z"
}
}보고서를 신뢰하기 전에 서명을 확인하세요: 웹훅 비밀 키로 t + "." + raw body의 HMAC-SHA256을 상수 시간에 비교하고 5분 이상 지난 시간을 거부하세요.
import { createHmac, timingSafeEqual } from "node:crypto";
// raw = the request body exactly as received (string), header = X-SendBulkSMS-Signature
export function verify(raw, header, secret) {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header || "");
if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
const want = Buffer.from(createHmac("sha256", secret).update(m[1] + "." + raw).digest("hex"));
const got = Buffer.from(m[2]);
return want.length === got.length && timingSafeEqual(want, got);
}import hmac, hashlib, re, time
def verify(raw: bytes, header: str, secret: str) -> bool:
m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", header or "")
if not m or abs(time.time() - int(m.group(1))) > 300:
return False
want = hmac.new(secret.encode(), m.group(1).encode() + b"." + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, m.group(2))<?php
function verify(string $raw, string $header, string $secret): bool {
if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $header, $m) || abs(time() - (int)$m[1]) > 300) return false;
$want = hash_hmac('sha256', $m[1] . '.' . $raw, $secret);
return hash_equals($want, $m[2]);
}상태
| 상태 | 의미 |
|---|---|
queued | 수락되어 요금이 청구되었으며, 대기열에서 순서를 기다리고 있어요 |
sent | 경로로 전달되었으며, 전송 리포트를 기다리는 중이에요 |
delivered | 네트워크에서 핸드폰으로 전송이 확인되었어요 |
failed | 전달되지 않았어요. 요금은 환불되고 error에 이유가 담겨요 |
canceled | 예약된 캠페인이 시작되기 전에 취소되어 환불되었어요 |
오류
에러는 HTTP 상태 코드와 JSON 본문을 사용해요: { "error": { "code": "insufficient_balance", "message": "..." } }. 에러가 발생한 요청은 잔액에서 요금이 청구되지 않아요.
| HTTP | 코드 | 발생 조건 |
|---|---|---|
| 400 | bad_request필드가 없거나 유효하지 않아요. 메시지에 어떤 필드인지 설명돼요 | 필드가 없거나 유효하지 않아요. 메시지에 어떤 필드인지 설명돼요 |
| 400 | no_valid_numbers"to"에 있는 번호 중 사용 가능한 번호가 없어요 | "to"에 있는 번호 중 사용 가능한 번호가 없어요 |
| 401 | unauthorizedAPI 키가 없거나 유효하지 않아요 | API 키가 없거나 유효하지 않아요 |
| 402 | insufficient_balance잔액이 부족해서 전송이 안 돼요; 아무것도 청구되지 않았어요 | 잔액이 부족해서 전송이 안 돼요; 아무것도 청구되지 않았어요 |
| 404 | not_found캠페인, 메시지 또는 엔드포인트가 계정에 존재하지 않아요 | 캠페인, 메시지 또는 엔드포인트가 계정에 존재하지 않아요 |
| 409 | not_scheduled아직 시작하지 않은 예약된 캠페인만 취소할 수 있어요 | 아직 시작하지 않은 예약된 캠페인만 취소할 수 있어요 |
| 413 | too_many_recipients한 요청의 번호가 10,000개를 넘었어요 | 한 요청의 번호가 10,000개를 넘었어요 |
| 429 | rate_limited이 키로 1분에 300번 이상 요청했어요 | 이 키로 1분에 300번 이상 요청했어요 |
| 500 | server_error다시 시도해 보세요. 실패한 요청에는 요금이 부과되지 않아요 | 다시 시도해 보세요. 실패한 요청에는 요금이 부과되지 않아요 |
제한
- API 키당 분당 300개의 요청.
- 전송 요청당 10,000개의 번호; 메시지당 10개의 세그먼트.
- 메시지는 분당 최대 200건까지 큐에 따라 순차적으로 발송돼요.
- 5분 후부터 60일 후까지 예약할 수 있어요.
- 발송 제외 목록에 있는 번호는 건너뛰고 dropped에 코드 suppressed로 반환돼요. 요금은 청구되지 않아요.