개요
쇼핑몰의 주문 업데이트, 로그인 양식의 코드, 예약 도구의 알림처럼 내 시스템이 메시지를 발생시킬 때 API를 사용하세요. 번호 최대 10,000개를 담은 POST /v1/messages 요청 하나가 캠페인 하나를 만들고, 잔액에서 요금을 차감하고, 모든 메시지를 대기열에 올려요. API로 만든 캠페인도 콘솔에 전송 리포트와 함께 나타나요.
- 모든 금액은 소수점 다섯 자리의 문자열로 된 미국 달러예요. 예: "0.00534".
- 전화번호는 국제 형식이며 앞의 +는 있어도 없어도 돼요. 공백과 하이픈은 제거돼요.
- 시각은 UTC 기준 ISO 8601이에요. 예: 2026-12-01T09:00:00Z.
- 메시지와 캠페인 ID는 MSG-XXXX-XXXX-XXXX, CMP-XXXX-XXXX-XXXX처럼 생겼어요.
인증
콘솔의 API에서 API 키를 만드세요(키는 첫 충전이 반영된 뒤에 열려요). 모든 요청의 Authorization 헤더에 키를 담아 보내세요. 키는 bso_api_ 뒤에 36자가 이어지는 형태이고, 한 번만 표시되니 서버에 비밀 값으로 보관하세요. 새 키를 만들면 이전 키는 즉시 교체돼요.
Authorization: Bearer bso_api_0123456789abcdef0123456789abcdef0123 Content-Type: application/json
한도
| 요청 | 키당 분당 300건, 초과하면 429 rate_limited로 응답해요 |
|---|---|
| 요청당 번호 수 | 10,000 |
| 메시지 문구 | 2,000자 그대로, 최대 10세그먼트 |
| 예약 발송 | send_at: 5분 후부터 60일 후까지 |
| 발송 속도 | 대기열당 분당 400건 |
| 웹훅 재시도 | 약 22시간 동안 9번 시도 |
메시지 발송
POST/v1/messages
하나 이상의 번호에 문자 하나를 보내요. 전체 비용은 요청이 접수될 때 잔액에서 차감되고, 전송되지 못한 메시지는 이후에 환불돼요. 응답은 상태 201과 모든 메시지가 담긴 캠페인이에요.
| 필드 | 유형 | 설명 |
|---|---|---|
to필수 | string | string[] | 국제 형식의 번호 하나 또는 최대 10,000개의 번호 배열이에요. 중복 번호는 한 번만 발송되고, 읽을 수 없는 번호는 skipped에 나열돼요. |
text필수 | string | 메시지이며 최대 2,000자, 10세그먼트예요. GSM-7 문구는 SMS당 160자, Unicode는 70자예요. |
send_at선택 | string | 발송을 예약하는 ISO 8601 시각으로, 지금부터 5분 후에서 60일 후까지예요. 생략하면 바로 보내요. |
opt_out선택 | boolean | true로 설정하면 콘솔 설정의 수신 거부 안내 문구가 마지막에 새 줄로 추가돼요. |
client_ref선택 | string | 직접 정하는 참조값으로, 문자와 숫자와 _ . : -를 합쳐 최대 64자예요. 리포트와 웹훅에 그대로 돌아와요. |
curl -X POST https://bulksmsonline.cc/api/v1/messages \
-H "Authorization: Bearer $BSO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"to":["+447700900412","+33612345678"],"text":"Order 4521 has shipped. Track it at shop.example/t/4521","client_ref":"order-4521"}'const res = await fetch("https://bulksmsonline.cc/api/v1/messages", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.BSO_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ to: ["+447700900412", "+33612345678"], text: "Order 4521 has shipped.", client_ref: "order-4521" }),
});
if (!res.ok) throw new Error((await res.json()).error.message);
const campaign = await res.json();import os, requests
res = requests.post("https://bulksmsonline.cc/api/v1/messages",
headers={"Authorization": f"Bearer {os.environ['BSO_API_KEY']}"},
json={"to": ["+447700900412", "+33612345678"], "text": "Order 4521 has shipped.", "client_ref": "order-4521"},
timeout=30)
res.raise_for_status()
campaign = res.json()<?php
$ch = curl_init("https://bulksmsonline.cc/api/v1/messages");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("BSO_API_KEY"), "Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["to" => ["+447700900412", "+33612345678"], "text" => "Order 4521 has shipped.", "client_ref" => "order-4521"])]);
$campaign = json_decode(curl_exec($ch), true);HTTP/1.1 201 Created
{
"id": "CMP-7KQ2-M9XA-4HTD",
"status": "sending",
"channel": "api",
"recipients": 2,
"segments": 1,
"encoding": "GSM-7",
"cost": "0.02158",
"refunded": "0.00000",
"client_ref": "order-4521",
"send_at": null,
"counts": { "queued": 2, "submitted": 0, "delivered": 0, "failed": 0, "canceled": 0 },
"skipped": [],
"balance": "118.43120",
"messages": [
{ "id": "MSG-3RTW-81KD-ZQ0P", "to": "+447700900412", "country": "GB", "segments": 1, "price": "0.00834", "status": "queued" },
{ "id": "MSG-5HNA-4C2E-7YVB", "to": "+33612345678", "country": "FR", "segments": 1, "price": "0.01324", "status": "queued" }
]
}발송 견적
POST/v1/quote
/v1/messages와 같은 본문을 받아서, 발송하거나 비용을 차감하지 않고 정확한 비용, 세그먼트, 인코딩을 돌려줘요. 사용자가 확정하기 전에 직접 만든 화면에서 요금을 보여줄 때 사용하세요.
{
"recipients": 2,
"skipped": [],
"segments": 1,
"encoding": "GSM-7",
"cost": "0.02158",
"balance": "118.45278",
"countries": [ { "country": "GB", "recipients": 1, "cost": "0.00834" }, { "country": "FR", "recipients": 1, "cost": "0.01324" } ]
}잔액
GET/v1/balance
{ "balance": "118.43120", "currency": "USD" }요금
GET/v1/rates
GET/v1/rates/{country}
모든 국가 또는 두 글자 ISO 국가 코드 하나에 대한 SMS 세그먼트 1건의 요금이에요. 이 두 엔드포인트는 공개되어 있어서 키가 필요 없어요.
{ "country": "DE", "name": "Germany", "calling_code": "+49", "price": "0.01544", "currency": "USD", "per": "segment" }메시지 상태
GET/v1/messages/{id}
{
"id": "MSG-3RTW-81KD-ZQ0P",
"campaign_id": "CMP-7KQ2-M9XA-4HTD",
"to": "+447700900412",
"country": "GB",
"segments": 1,
"price": "0.00834",
"status": "delivered",
"error": null,
"created_at": "2026-11-07T09:00:02Z",
"submitted_at": "2026-11-07T09:00:03Z",
"final_at": "2026-11-07T09:00:08Z"
}캠페인
GET/v1/campaigns/{id}
GET/v1/campaigns/{id}/messages?offset=0&limit=100
첫 번째는 상태별 건수가 담긴 캠페인을 돌려주고, 두 번째는 해당 캠페인의 메시지를 페이지당 최대 1,000건씩 보여줘요. 콘솔에서 보낸 캠페인도 페이지에 표시된 ID로 같은 방식으로 조회할 수 있어요.
예약 발송 취소
POST/v1/campaigns/{id}/cancel
send_at으로 만들어졌고 아직 시작하지 않은 캠페인을 중지해요. 모든 메시지가 취소되고 비용 전액이 잔액으로 돌아와요. 이미 발송 중인 캠페인은 409 not_scheduled로 응답해요.
전송 웹훅
콘솔의 API에서 HTTPS URL을 추가하면 각 최종 상태(delivered 또는 failed)가 POST 요청으로 전달돼요. 8초 안에 아무 2xx 상태로 응답하세요. 그 밖의 응답과 시간 초과는 20초, 1분, 5분, 15분 뒤에, 이어서 1시간, 3시간, 6시간, 12시간 뒤에 다시 시도하며 총 9번이에요. 같은 이벤트가 두 번 도착할 수 있으니 event_id로 한 번만 처리하세요.
POST https://your-server.example/sms-status
Content-Type: application/json
X-BulkSMSOnline-Signature: t=1794042008,v1=5f0c2a...e91b
{
"type": "message.status",
"event_id": "ev_8f1c22d0a4b97e63c1f0",
"sent_at": "2026-11-07T09:00:09Z",
"data": {
"message_id": "MSG-3RTW-81KD-ZQ0P",
"campaign_id": "CMP-7KQ2-M9XA-4HTD",
"to": "+447700900412",
"country": "GB",
"status": "delivered",
"error": null,
"segments": 1,
"price": "0.00834",
"currency": "USD",
"client_ref": "order-4521",
"final_at": "2026-11-07T09:00:08Z"
}
}서명 검증
헤더에는 Unix 시각 t와 서명 v1이 담겨 있어요. v1은 웹훅 시크릿으로 문자열 "t.raw_body"에 HMAC-SHA256을 계산한 hex 값이에요. JSON을 파싱하기 전에 원본 본문으로 계산하고, 일정한 시간으로 비교하고, 5분보다 오래된 이벤트는 거부하세요.
import crypto from "node:crypto";
export function verify(rawBody, 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 = crypto.createHmac("sha256", secret).update(m[1] + "." + rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(want), Buffer.from(m[2]));
}import hmac, hashlib, re, time
def verify(raw_body: 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_body, 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)) return false;
if (abs(time() - (int)$m[1]) > 300) return false;
$want = hash_hmac('sha256', $m[1] . '.' . $raw, $secret);
return hash_equals($want, $m[2]);
}상태
| queued | 접수되어 대기열에서 나가기를 기다리는 중 |
|---|---|
| submitted | 목적지 통신망에 전달됨 |
| delivered | 휴대폰이 수신을 확인함(최종) |
| failed | 전달하지 못했고 요금이 환불됨(최종) |
| canceled | 발송 전에 취소된 예약 캠페인의 일부(최종) |
캠페인 상태는 scheduled, sending, complete, canceled 중 하나예요.
오류
오류는 HTTP 상태와 JSON 본문으로 응답해요: { "error": { "code": "insufficient_balance", "message": "..." } }. 요청이 오류로 응답되면 요금은 부과되지 않아요.
| HTTP | 코드 | 의미 |
|---|---|---|
| 400 | invalid_request | 필드가 없거나 올바르지 않아요. 메시지에 어느 필드인지 나와요. |
| 400 | invalid_json | 본문이 JSON이 아니에요. |
| 400 | no_valid_numbers | 읽을 수 있는 번호가 하나도 없어요. |
| 401 | unauthorized | 키가 없거나, 형식이 잘못되었거나, 교체되었어요. |
| 402 | insufficient_balance | 잔액이 비용에 부족해요. 충전한 뒤 다시 보내세요. |
| 404 | not_found | 알 수 없는 ID 또는 엔드포인트예요. |
| 409 | not_scheduled | 아직 시작하지 않은 예약 캠페인만 취소할 수 있어요. |
| 413 | too_many_recipients | 요청 하나에 번호가 10,000개를 넘어요. |
| 429 | rate_limited | 1분 동안 요청이 300건을 넘어요. |
| 500 | server_error | 요청을 다시 시도하세요. 요금은 부과되지 않았어요. |