Q 누가 운영하나요?
AI 팀이 개발·모니터링·운영을 상시 맡고 있는 서비스입니다(소개). 2025년 9월에 열었고, 그 뒤 가용성은 99.8% 수준입니다. 서버와 17개 택배사의 통신 상태는 자동 모니터링이 24시간 지켜보고, 끊기면 알림이 옵니다.
추석 기념 비즈니스 체험판을 10월 31일까지 엽니다. 결제 정보는 받지 않습니다.
체험하기Reference
운송장번호 하나로 국내외 17개 택배사의 배송 상태를 조회합니다.
택배사 코드를 몰라도 15개 택배사가 자동 감지 대상입니다.
reliable 다섯 곳은 1순위 적중률 97% 이상, EMS·해외직구·투데이는 국제/전용 형식이면 접두어로,
나머지 7개는 잴 만큼의 표본이 아직 없어 1순위를 놓칠 수 있습니다
(등급별 실측).
요금 정책
현재 무료입니다
개발·상용 서비스 도입 시 무상 제공
시스템 가동 상태
24시간 자동 감시 정상 가동
2025.09 출시 · 17개사 상시 감시
서비스 연속성
전용 인프라 운영
서버(연단위 운영) 인프라 지속 관리
정책 변경 기준
사전 공지 원칙
서비스·정책 개편 시 사전 유예 제공
키(KEY)를 발급받고 한 번 호출하기까지 두 단계입니다.
curl https://taekbae.net/v1/tracking/12345678901 \
-H "Authorization: Bearer $TAEKBAE_API_KEY"
문서의 12345678901은 실제 배송 건이 아닌 예시용 번호입니다. 그대로 실행하면 자동 감지가 후보를 특정하지 못해 422 carrier_not_detected가 돌아옵니다(후보로 재시도하는 법). 아래 200 본문은 실제 배송 건을 조회했을 때의 모양이니, 손에 있는 운송장번호로 바꿔 실행하세요.
키(KEY)도 실 운송장번호도 아직 없다면 위 단계를 건너뛰고 샌드박스 키로 먼저 200을 보세요. 발급 없이 바로 되고, 문서 안에서 버튼 한 번으로 실행해 볼 수도 있습니다.
{
"tracking_number": "12345678901",
"carrier": { "code": "cj", "name": "CJ대한통운", "detected": true },
"status": { "code": "linehaul", "label": "간선 이동 중", "is_final": false },
"last_event_at": "2026-07-26T09:12:00+09:00",
"last_event_at_raw": "2026-07-26 09:12:00",
"last_event_at_precision": "exact",
"events": [
{
"occurred_at": "2026-07-26T09:12:00+09:00",
"occurred_at_raw": "2026-07-26 09:12:00",
"time_precision": "exact",
"location": "대전HUB",
"status_text": "간선상차",
"stage": "linehaul"
}
],
"as_of": "2026-07-26T18:06:00+09:00"
}
eta 키가 아예 없습니다. null이 아니라 키 자체가 빠지는 것이 이 API의 규칙입니다.
curl "https://taekbae.net/v1/tracking/12345678901?eta=true" \
-H "Authorization: Bearer $TAEKBAE_API_KEY"
{
"tracking_number": "12345678901",
"carrier": { "code": "cj", "name": "CJ대한통운", "detected": true },
"status": { "code": "linehaul", "label": "간선 이동 중", "is_final": false },
"last_event_at": "2026-07-26T09:12:00+09:00",
"last_event_at_raw": "2026-07-26 09:12:00",
"last_event_at_precision": "exact",
"eta": {
"status": "available",
"estimated_delivery_from": "2026-07-26T19:33:00+09:00",
"estimated_delivery_to": "2026-07-28T20:59:00+09:00",
"p50_remaining_minutes": 1313,
"p90_remaining_minutes": 3053,
"confidence": "low",
"support_count": 535,
"fallback_level": "community_prior",
"model_version": "empirical-quantile-2026-07-18.1",
"as_of": "2026-07-26T18:06:00+09:00"
},
"events": [
{
"occurred_at": "2026-07-26T09:12:00+09:00",
"occurred_at_raw": "2026-07-26 09:12:00",
"time_precision": "exact",
"location": "대전HUB",
"status_text": "간선상차",
"stage": "linehaul"
}
],
"as_of": "2026-07-26T18:06:00+09:00"
}
위 예시의 confidence가 "low"인 것에 주목하세요. 도착 예측은 구간과 신뢰도로 오고, 단일 시각이 아닙니다. 필드별 의미
| 필드 | 설명 |
|---|---|
tracking_number | 정규화된 운송장번호. 하이픈을 지우고 영문을 대문자로 올린 값이라 보낸 값과 다를 수 있습니다. |
carrier | code · name으로 택배사를 식별합니다. "detected": true면 자동 감지된 값입니다(요청에 carrier를 넣지 않은 경우). "detected": false면 요청에서 지정한 값 그대로라는 뜻입니다(예: ?carrier=cj로 요청하면 필드가 빠지지 않고 그대로 false가 옵니다). 운송장번호는 택배사마다 따로 발급되는 값이라, 드물지만 서로 다른 택배사가 우연히 같은 번호로 실제 배송 건을 각자 갖고 있을 수 있습니다. 그러면 추론이 맞았다고 믿어도 남의 화물일 수 있으니, true일 때는 사용자에게 "다른 택배사인가요?"를 물을 근거로 쓰세요. |
status | 분기는 code로 하세요(label은 화면 문구라 바뀔 수 있습니다). is_final이 true면 배송완료·반송·취소처럼 더 이상 바뀌지 않는 상태이므로, 폴링 종료 조건으로 쓰세요. |
last_event_at | 가장 최근 이벤트의 발생 시각. 변화 감지에 쓰기 좋습니다. 택배사가 시각을 안 준 이벤트에서는 이 키가 없고(last_event_at_precision이 date_only) 날짜는 last_event_at_raw에 있습니다. |
events[] | 배송 이력. 최신순이 아니라 발생 순(오래된 것부터)입니다. 각 항목의 stage는 status.code와 같은 값 집합(분기용), status_text는 택배사가 준 원문 문구(화면용), occurred_at은 발생 시각입니다. |
eta BETA | 도착 예측. ?eta=true로 요청했을 때만 옵니다. confidence가 low면 고객에게 그대로 약속하지 말고 참고값으로만 쓰세요. |
as_of | 택배사에서 정보를 가져온 시각. 응답을 만든 시각이 아닙니다. |
위는 자주 보는 필드만 간추린 것입니다. 전체 필드와 parties · delivery_agent 등 하위 객체는 응답 필드 전체 보기 →
도착 예측(eta)은 기본으로 오지 않습니다.
아직 정확도가 충분하지 않아 옵트인으로 두었습니다.
달라고 하지 않은 응답에 실어 보내면 그 값을 그대로 믿고 고객에게
도착 시간을 안내하거나 늦었는지 판단하는 데 쓰게 되기 때문입니다.
필요하면 ?eta=true로 켜되,
confidence와 support_count를 함께 읽고
참고값으로만 쓰세요.
as_of는 응답을 만든 시각이 아니라 택배사에서 정보를 가져온 시각입니다. 캐시된 응답을 받으면 이 값이 조금 과거일 수 있고, 그게 정확한 표현입니다.
판매자가 등록만 해 두고 아직 택배사에 넘기지 않은 운송장("가송장")도 이 API에서는 정상 응답으로 옵니다.
tracking_number_not_found가 아니라 status.code가
accepted인 채로 며칠씩 멈춰 있는 식입니다.
가송장 구분하는 법 →
코드를 직접 쓰지 않고 AI 코딩 도구에 맡길 수도 있습니다.
길게 설명할 필요가 없습니다. 엔드포인트·필드·오류 코드는 물론
"폴링은 is_final로 멈춰라", "date_only면 00:00으로 읽지 마라" 같은
주의사항까지 스펙 설명 안에 들어 있습니다.
시작 전에 준비할 것
https://taekbae.net/v1/openapi.jsonhttps://taekbae.net/v1/openapi.json을 읽고, 이 스펙을 기준으로 택배 배송조회를
지금 프로젝트에 구현해 주세요. 배송의 어떤 상태를 어떻게 보여줄지는 아래 요구에
맞춰 정하고, 구현하는 범위 안에서는 스펙의 엔드포인트·필드·오류 코드·주의사항을
빠뜨리지 말아 주세요.
요구: (예: 주문 목록에 현재 배송 상태만 배지로 표시)
문서를 요약해서 붙여 넣지 말고, 위 OpenAPI 스펙 주소를 그대로 주세요. 요약은 만드는 순간의 스냅샷이라 그 뒤에 스펙이 바뀌면 안 맞게 됩니다. 스펙 주소는 서버가 지금 상태를 그대로 만들어 내보내므로, 언제 열어도 실제 동작과 같습니다.
스펙 전체를 다 구현할 필요는 없습니다. 배송 상태만 배지로 보여 주면 되는 화면에 이동 이력·예상 도착까지 만들면 군더더기가 됩니다. 필요한 화면을 «요구»에 적어 범위를 좁히세요. 다만 만들기로 한 범위 안에서는 오류 코드와 주의사항을 빼지 마세요. 자동 감지 실패(422)나 한도 초과(429) 처리를 건너뛰면 평소에는 잘 되다가 실제 상황에서 무너집니다.
코드를 짜기 전에 터미널에서 한 번 찔러 보는 쪽이 빠릅니다. 키(KEY)가 맞는지, 자동 감지가 되는지, 응답이 어떤 모양인지 여기서 확인하세요.
# macOS · Linux · WSL
export TAEKBAE_API_KEY="tb_xxxxxxxxxxxxxxxxxxxx"
# 기본 조회 (택배사 자동 감지, ETA 없음)
curl -s "https://taekbae.net/v1/tracking/12345678901" \
-H "Authorization: Bearer $TAEKBAE_API_KEY" | jq
# 택배사를 지정하고 최신 이벤트만
curl -s "https://taekbae.net/v1/tracking/12345678901?carrier=cj&events=latest" \
-H "Authorization: Bearer $TAEKBAE_API_KEY" | jq
# 도착 예측까지 (기본은 오지 않습니다)
curl -s "https://taekbae.net/v1/tracking/12345678901?eta=true" \
-H "Authorization: Bearer $TAEKBAE_API_KEY" | jq '.eta'
# 내 쪽 주문번호를 실어 보내면 응답에 그대로 돌아옵니다 (client_id)
curl -s "https://taekbae.net/v1/tracking/12345678901?client_id=order-1001" \
-H "Authorization: Bearer $TAEKBAE_API_KEY" | jq '.client_id'
# 상태코드와 헤더까지 보기: ETag·RateLimit-Remaining을 확인할 때
curl -i -s "https://taekbae.net/v1/tracking/12345678901" \
-H "Authorization: Bearer $TAEKBAE_API_KEY" | head -20
# Windows PowerShell 5.1 / PowerShell 7+
$env:TAEKBAE_API_KEY = "tb_xxxxxxxxxxxxxxxxxxxx"
$headers = @{ Authorization = "Bearer $env:TAEKBAE_API_KEY" }
# 기본 조회
Invoke-RestMethod -Uri "https://taekbae.net/v1/tracking/12345678901" `
-Headers $headers | ConvertTo-Json -Depth 6
# 택배사 지정 + 최신 이벤트만
Invoke-RestMethod -Uri "https://taekbae.net/v1/tracking/12345678901?carrier=cj&events=latest" `
-Headers $headers | ConvertTo-Json -Depth 6
# 헤더까지 보려면 Invoke-WebRequest: ETag는 여기서만 읽힙니다
$res = Invoke-WebRequest -Uri "https://taekbae.net/v1/tracking/12345678901" -Headers $headers
$res.Headers["ETag"]
$res.Headers["RateLimit-Remaining"]
# 조건부 요청: 내용이 그대로면 304, 쿼터도 차감되지 않습니다.
# PowerShell은 304를 오류로 던지므로 잡아서 처리합니다.
try {
$headers["If-None-Match"] = $res.Headers["ETag"]
Invoke-WebRequest -Uri "https://taekbae.net/v1/tracking/12345678901" -Headers $headers
} catch [System.Net.WebException] {
if ($_.Exception.Response.StatusCode.value__ -eq 304) { "변화 없음" }
else { throw }
}
# pip install httpie
export TAEKBAE_API_KEY="tb_xxxxxxxxxxxxxxxxxxxx"
# 기본 조회: 헤더와 본문을 함께 보여줍니다
http GET https://taekbae.net/v1/tracking/12345678901 \
"Authorization: Bearer $TAEKBAE_API_KEY"
# 쿼리 파라미터는 == 로 붙입니다
http GET https://taekbae.net/v1/tracking/12345678901 \
carrier==cj events==latest eta==true \
"Authorization: Bearer $TAEKBAE_API_KEY"
12345678901은 예시용 번호입니다. 그대로 실행하면 자동 감지가 후보를 특정하지 못해 422 carrier_not_detected가 돌아옵니다. 손에 있는 운송장번호로 바꿔 실행하세요.
조회 한 번과 상태별 분기를 담은 최소 예제입니다. 오류를 code로 분기하는 부분이 핵심입니다. title 문구는 바뀔 수 있지만 code는 유지됩니다.
일곱 예제는 같은 계약을 구현합니다. tracking_number_not_found · carrier_not_detected · quota_exceeded · carrier_unavailable · carrier_error를 모두 분기하고, 304(변화 없음)와 404(운송장 없음)를 서로 다른 반환 채널로 돌려주며, 429 · 503의 Retry-After를 호출자에게 전달합니다. 조회에 성공하면 본문과 함께 ETag라는 일종의 "이 결과의 버전 값"도 돌려줍니다. 다음 조회 때 이 값을 그대로 실어 보내면(If-None-Match 헤더), 그 사이 내용이 안 바뀌었을 때 서버가 본문 없이 짧게 "그대로예요"(304)라고만 답해서 더 가볍고 빠릅니다. 자세한 사용법은 캐시와 폴링을 보세요.
# pip install requests==2.34.2 (표준 라이브러리만으로는 돌지 않습니다)
import os, requests
BASE = "https://taekbae.net/v1"
SESSION = requests.Session()
SESSION.headers["Authorization"] = f"Bearer {os.environ['TAEKBAE_API_KEY']}"
class TrackError(Exception):
"""API 오류의 뿌리. retry_after는 429·503에서만 채워진다."""
def __init__(self, message, retry_after=None):
super().__init__(message)
self.retry_after = retry_after
class NotFound(TrackError): """404. 그 운송장이 택배사에 없다(304와 다르다)."""
class CarrierNotDetected(TrackError): """422. 후보가 없다. carrier를 지정해야 한다."""
class QuotaExceeded(TrackError): """429. retry_after만큼 기다린다."""
class CarrierUnavailable(TrackError): """503. 택배사 장애(운송장이 없는 것과 다르다)."""
class CarrierError(TrackError): """502. 상류 실패. 재시도 가능하고 차감도 없다."""
def track(number, carrier=None, etag=None):
"""(결과, ETag)를 돌려준다. 내용이 그대로면 결과는 None이고 ETag는 유지된다."""
r = SESSION.get(f"{BASE}/tracking/{number}",
params={"carrier": carrier} if carrier else {},
headers={"If-None-Match": etag} if etag else {},
timeout=15)
if r.status_code == 304: # 변화 없음. 쿼터 차감 없음
return None, etag
if r.status_code == 200:
return r.json(), r.headers.get("ETag")
problem = r.json()
code, title, rid = problem["code"], problem["title"], problem["request_id"]
retry_after = r.headers.get("Retry-After")
retry_after = int(retry_after) if retry_after else None
if code == "tracking_number_not_found":
raise NotFound(title) # 차감된다. 폴링을 멈춘다
if code == "carrier_not_detected":
# candidates에는 **아직 시도하지 않은** 후보만 온다. 비어 있으면
# 더 해볼 게 없으니 사용자에게 택배사를 물어야 한다.
candidates = problem.get("candidates") or []
if not candidates:
return None, None
return track(tracking_number, carrier=candidates[0]["code"])
if code == "quota_exceeded":
raise QuotaExceeded(title, retry_after)
if code == "carrier_unavailable":
raise CarrierUnavailable(title, retry_after)
if code == "carrier_error":
raise CarrierError(f"{title} (request_id={rid})") # 재시도 가능
raise TrackError(f"{code}: {title} (request_id={rid})")
try:
result, etag = track("12345678901", carrier="cj")
if result is None:
print("변화 없음") # 304. 가지고 있던 결과를 그대로 쓴다
else:
print(result["status"]["label"], result["status"]["is_final"])
except NotFound:
print("아직 등록되지 않은 운송장입니다") # 404를 '변화 없음'으로 읽지 않는다
// Node.js 18+ (전역 fetch 필요). 브라우저에서 직접 부르지 마세요.
// 클라이언트 코드에 API 키를 심으면 그대로 유출됩니다. 반드시 서버를 거치세요.
const BASE = "https://taekbae.net/v1";
const HEADERS = { Authorization: `Bearer ${process.env.TAEKBAE_API_KEY}` };
// retryAfter는 429·503에서만 채워진다.
class TrackError extends Error {
constructor(message, retryAfter = null) {
super(message);
this.retryAfter = retryAfter;
}
}
class NotFound extends TrackError {} // 404. 그 운송장이 택배사에 없다(304와 다르다).
class CarrierNotDetected extends TrackError {} // 422. 후보가 없다. carrier를 지정해야 한다.
class QuotaExceeded extends TrackError {} // 429. retryAfter만큼 기다린다.
class CarrierUnavailable extends TrackError {} // 503. 택배사 장애(운송장이 없는 것과 다르다).
class CarrierError extends TrackError {} // 502. 상류 실패. 재시도 가능하고 차감도 없다.
// { result, etag }를 돌려준다. 내용이 그대로면 result는 null이고 etag는 유지된다.
async function track(number, { carrier, etag } = {}) {
const url = new URL(`${BASE}/tracking/${number}`);
if (carrier) url.searchParams.set("carrier", carrier);
const res = await fetch(url, {
headers: { ...HEADERS, ...(etag ? { "If-None-Match": etag } : {}) },
});
if (res.status === 304) return { result: null, etag }; // 변화 없음. 쿼터 차감 없음
if (res.status === 200) {
return { result: await res.json(), etag: res.headers.get("ETag") };
}
const problem = await res.json();
const { code, title, request_id: requestId } = problem;
const retryAfterHeader = res.headers.get("Retry-After");
const retryAfter = retryAfterHeader ? Number(retryAfterHeader) : null;
if (code === "tracking_number_not_found") throw new NotFound(title); // 차감된다. 폴링을 멈춘다
if (code === "carrier_not_detected") {
// candidates에는 아직 시도하지 않은 후보만 온다. 비어 있으면
// 더 해볼 게 없으니 사용자에게 택배사를 물어야 한다.
const candidates = problem.candidates || [];
if (candidates.length === 0) return { result: null, etag: null };
return track(number, { carrier: candidates[0].code });
}
if (code === "quota_exceeded") throw new QuotaExceeded(title, retryAfter);
if (code === "carrier_unavailable") throw new CarrierUnavailable(title, retryAfter);
if (code === "carrier_error") throw new CarrierError(`${title} (request_id=${requestId})`); // 재시도 가능
throw new TrackError(`${code}: ${title} (request_id=${requestId})`);
}
// 최상위 await는 파일이 모듈일 때만 허용됩니다(import/export가 없으면
// 일반 스크립트로 취급됩니다). import/export를 안 쓰는 프로젝트에서도
// 그대로 동작하도록 즉시실행 비동기 함수로 감쌉니다.
(async () => {
try {
const { result, etag } = await track("12345678901", { carrier: "cj" });
if (result === null) {
console.log("변화 없음"); // 304. 가지고 있던 결과를 그대로 쓴다
} else {
console.log(result.status.label, result.status.is_final);
}
} catch (err) {
if (err instanceof NotFound) {
console.log("아직 등록되지 않은 운송장입니다"); // 404를 '변화 없음'으로 읽지 않는다
} else {
throw err;
}
}
})();
// Node.js 18+ (전역 fetch 필요) 또는 최신 브라우저. tsc 5.x 기준.
// 브라우저에서 직접 부르지 마세요. 키는 반드시 서버를 거쳐 넘기세요.
const BASE = "https://taekbae.net/v1";
const HEADERS = { Authorization: `Bearer ${process.env.TAEKBAE_API_KEY}` };
interface TrackingStatus {
code: string;
label: string;
is_final: boolean;
}
// 전체 필드는 위 "응답 필드" 표 또는 OpenAPI 스펙을 참고하세요. 자주 쓰는
// 필드만 타입을 달았습니다.
interface Tracking {
tracking_number: string;
carrier: { code: string; name: string; detected: boolean };
status: TrackingStatus;
last_event_at?: string; // exact일 때만 옵니다
last_event_at_raw?: string; // 원문(날짜만 아는 이벤트면 여기서 날짜를 꺼내세요)
last_event_at_precision?: "exact" | "date_only";
events: unknown[];
eta?: Record; // ?eta=true로 요청했을 때만 옵니다(BETA)
as_of: string;
}
interface Problem {
code: string;
title: string;
request_id: string;
candidates?: { code: string; name: string }[];
}
interface TrackResult {
result: Tracking | null;
etag: string | null;
}
// retryAfter는 429·503에서만 채워진다.
class TrackError extends Error {
constructor(message: string, public retryAfter: number | null = null) {
super(message);
}
}
class NotFound extends TrackError {} // 404. 그 운송장이 택배사에 없다(304와 다르다).
class CarrierNotDetected extends TrackError {} // 422. 후보가 없다. carrier를 지정해야 한다.
class QuotaExceeded extends TrackError {} // 429. retryAfter만큼 기다린다.
class CarrierUnavailable extends TrackError {} // 503. 택배사 장애(운송장이 없는 것과 다르다).
class CarrierError extends TrackError {} // 502. 상류 실패. 재시도 가능하고 차감도 없다.
// result·etag를 돌려준다. 내용이 그대로면 result는 null이고 etag는 유지된다.
async function track(
number: string,
{ carrier, etag }: { carrier?: string; etag?: string } = {}
): Promise {
const url = new URL(`${BASE}/tracking/${number}`);
if (carrier) url.searchParams.set("carrier", carrier);
const res = await fetch(url, {
headers: { ...HEADERS, ...(etag ? { "If-None-Match": etag } : {}) },
});
if (res.status === 304) return { result: null, etag: etag ?? null }; // 변화 없음. 쿼터 차감 없음
if (res.status === 200) {
return { result: (await res.json()) as Tracking, etag: res.headers.get("ETag") };
}
const problem = (await res.json()) as Problem;
const { code, title, request_id: requestId } = problem;
const retryAfterHeader = res.headers.get("Retry-After");
const retryAfter = retryAfterHeader ? Number(retryAfterHeader) : null;
if (code === "tracking_number_not_found") throw new NotFound(title); // 차감된다. 폴링을 멈춘다
if (code === "carrier_not_detected") {
// candidates에는 아직 시도하지 않은 후보만 온다. 비어 있으면
// 더 해볼 게 없으니 사용자에게 택배사를 물어야 한다.
const candidates = problem.candidates ?? [];
if (candidates.length === 0) return { result: null, etag: null };
return track(number, { carrier: candidates[0].code });
}
if (code === "quota_exceeded") throw new QuotaExceeded(title, retryAfter);
if (code === "carrier_unavailable") throw new CarrierUnavailable(title, retryAfter);
if (code === "carrier_error") throw new CarrierError(`${title} (request_id=${requestId})`); // 재시도 가능
throw new TrackError(`${code}: ${title} (request_id=${requestId})`);
}
// 최상위 await는 파일이 모듈일 때만 허용됩니다(import/export가 없으면
// 일반 스크립트로 취급됩니다). import/export를 안 쓰는 프로젝트에서도
// 그대로 동작하도록 즉시실행 비동기 함수로 감쌉니다.
(async () => {
try {
const { result, etag } = await track("12345678901", { carrier: "cj" });
if (result === null) {
console.log("변화 없음"); // 304. 가지고 있던 결과를 그대로 쓴다
} else {
console.log(result.status.label, result.status.is_final);
}
} catch (err) {
if (err instanceof NotFound) {
console.log("아직 등록되지 않은 운송장입니다"); // 404를 '변화 없음'으로 읽지 않는다
} else {
throw err;
}
}
})();
// Maven: com.fasterxml.jackson.core:jackson-databind:2.19.0 (JDK 17+, HTTP는 표준 java.net.http)
import java.io.IOException;
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
public final class TaekbaeClient {
private static final String BASE = "https://taekbae.net/v1";
/** 내용이 그대로면 body가 null이고 etag는 유지된다. 404와 섞이지 않는다. */
public record Tracked(JsonNode body, String etag) {}
/** retryAfter는 429·503에서만 채워진다. */
public static class TrackException extends Exception {
public final Integer retryAfter;
public TrackException(String m, Integer retryAfter) { super(m); this.retryAfter = retryAfter; }
}
/** 404. 그 운송장이 택배사에 없다. 차감되므로 폴링을 멈춘다. */
public static final class NotFound extends TrackException {
public NotFound(String m) { super(m, null); }
}
/** 422. 후보가 없다. carrier를 지정해야 한다. */
public static final class CarrierNotDetected extends TrackException {
public CarrierNotDetected(String m) { super(m, null); }
}
/** 429. retryAfter만큼 기다린다. */
public static final class QuotaExceeded extends TrackException {
public QuotaExceeded(String m, Integer r) { super(m, r); }
}
/** 503. 택배사 장애. 운송장이 없는 것과 다르다. */
public static final class CarrierUnavailable extends TrackException {
public CarrierUnavailable(String m, Integer r) { super(m, r); }
}
/** 502. 상류 실패. 재시도 가능하고 차감도 없다. */
public static final class CarrierError extends TrackException {
public CarrierError(String m) { super(m, null); }
}
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)).build();
private final ObjectMapper json = new ObjectMapper();
private final String apiKey;
public TaekbaeClient(String apiKey) { this.apiKey = apiKey; }
public Tracked track(String number, String carrier, String etag)
throws TrackException, IOException, InterruptedException {
String url = BASE + "/tracking/" + number
+ (carrier == null ? "" : "?carrier=" + carrier);
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(url))
.header("Authorization", "Bearer " + apiKey)
.timeout(Duration.ofSeconds(15));
if (etag != null) req.header("If-None-Match", etag);
HttpResponse<String> res =
http.send(req.build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() == 304) return new Tracked(null, etag); // 변화 없음. 차감 없음
if (res.statusCode() == 200)
return new Tracked(json.readTree(res.body()),
res.headers().firstValue("ETag").orElse(null));
JsonNode problem = json.readTree(res.body());
String code = problem.path("code").asText();
String title = problem.path("title").asText();
String rid = problem.path("request_id").asText();
Integer retryAfter = res.headers().firstValue("Retry-After")
.map(Integer::parseInt).orElse(null);
switch (code) {
case "tracking_number_not_found":
throw new NotFound(title);
case "carrier_not_detected": {
// candidates에는 아직 시도하지 않은 후보만 온다. 빌 수 있다.
JsonNode candidates = problem.path("candidates");
if (!candidates.isArray() || candidates.isEmpty()) return null;
return track(number, candidates.get(0).get("code").asText(), null);
}
case "quota_exceeded":
throw new QuotaExceeded(title, retryAfter);
case "carrier_unavailable":
throw new CarrierUnavailable(title, retryAfter);
case "carrier_error":
throw new CarrierError(title + " (request_id=" + rid + ")");
default:
throw new TrackException(code + ": " + title + " (request_id=" + rid + ")", null);
}
}
// 사용 예. 호출부가 checked exception을 던지므로 여기서만 try/catch로 받는다.
public static void main(String[] args) throws Exception {
TaekbaeClient client = new TaekbaeClient(System.getenv("TAEKBAE_API_KEY"));
try {
Tracked tracked = client.track("12345678901", "cj", null);
if (tracked.body() == null) {
System.out.println("변화 없음"); // 304. 가지고 있던 결과를 그대로 쓴다
} else {
System.out.println(tracked.body().path("status").path("label").asText());
}
} catch (NotFound e) {
System.out.println("아직 등록되지 않은 운송장입니다"); // 404를 '변화 없음'으로 읽지 않는다
}
}
}
// Gradle: org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0 (JDK 17+, HTTP는 표준 java.net.http)
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
import kotlinx.serialization.json.*
private const val BASE = "https://taekbae.net/v1"
/** 내용이 그대로면 body가 null이고 etag는 유지된다. 404와 섞이지 않는다. */
data class Tracked(val body: JsonObject?, val etag: String?)
/** retryAfterSeconds는 429·503에서만 채워진다. */
open class TrackException(message: String, val retryAfterSeconds: Long? = null) : Exception(message)
class NotFound(message: String) : TrackException(message) // 404. 차감된다. 폴링을 멈춘다
class CarrierNotDetected(message: String) : TrackException(message) // 422. 후보가 없다
class QuotaExceeded(message: String, retryAfter: Long?) : TrackException(message, retryAfter)
class CarrierUnavailable(message: String, retryAfter: Long?) : TrackException(message, retryAfter)
class CarrierError(message: String) : TrackException(message) // 502. 재시도 가능
class TaekbaeClient(private val apiKey: String) {
private val http: HttpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)).build()
fun track(number: String, carrier: String? = null, etag: String? = null): Tracked {
val url = buildString {
append("$BASE/tracking/$number")
carrier?.let { append("?carrier=$it") }
}
val req = HttpRequest.newBuilder(URI.create(url))
.header("Authorization", "Bearer $apiKey")
.timeout(Duration.ofSeconds(15))
.apply { etag?.let { header("If-None-Match", it) } }
.build()
val res = http.send(req, HttpResponse.BodyHandlers.ofString())
if (res.statusCode() == 304) return Tracked(null, etag) // 변화 없음. 차감 없음
if (res.statusCode() == 200) return Tracked(
Json.parseToJsonElement(res.body()).jsonObject,
res.headers().firstValue("ETag").orElse(null),
)
val problem = Json.parseToJsonElement(res.body()).jsonObject
val code = problem["code"]?.jsonPrimitive?.content.orEmpty()
val title = problem["title"]?.jsonPrimitive?.content.orEmpty()
val rid = problem["request_id"]?.jsonPrimitive?.content.orEmpty()
val retryAfter = res.headers().firstValue("Retry-After").orElse(null)?.toLongOrNull()
return when (code) {
"tracking_number_not_found" -> throw NotFound(title)
"carrier_not_detected" -> {
// candidates에는 아직 시도하지 않은 후보만 온다. 빌 수 있다.
val first = problem["candidates"]?.jsonArray?.firstOrNull()
?.jsonObject?.get("code")?.jsonPrimitive?.content
if (first == null) Tracked(null, null) else track(number, carrier = first)
}
"quota_exceeded" -> throw QuotaExceeded(title, retryAfter)
"carrier_unavailable" -> throw CarrierUnavailable(title, retryAfter)
"carrier_error" -> throw CarrierError("$title (request_id=$rid)")
else -> throw TrackException("$code: $title (request_id=$rid)")
}
}
}
fun main() {
val client = TaekbaeClient(System.getenv("TAEKBAE_API_KEY"))
try {
val (body, _) = client.track("12345678901", carrier = "cj")
if (body == null) println("변화 없음") // 304
else println(body["status"]!!.jsonObject["label"]!!.jsonPrimitive.content)
} catch (e: NotFound) {
println("아직 등록되지 않은 운송장입니다") // 404
}
}
package taekbae
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strconv"
"time"
)
const base = "https://taekbae.net/v1"
var (
ErrNotFound = errors.New("운송장 정보 없음") // 404. 304(변화 없음)와 다르다
ErrNotDetected = errors.New("택배사 미확정") // 422. 후보가 비었다
ErrQuotaExceeded = errors.New("요청 한도 초과") // 429
ErrCarrierUnavailable = errors.New("택배사 장애") // 503. 운송장 없음과 다르다
ErrCarrierError = errors.New("택배사 응답 실패") // 502. 재시도 가능
)
// RetryError는 다시 부르면 되는 오류다. 쿼터도 차감되지 않는다.
// RetryAfter가 0이면 서버가 알려주지 않은 것이다.
type RetryError struct {
Err error
Title string
RetryAfter time.Duration
}
func (e *RetryError) Error() string { return fmt.Sprintf("%v: %s", e.Err, e.Title) }
func (e *RetryError) Unwrap() error { return e.Err }
type Client struct {
APIKey string
HTTP *http.Client
}
type Problem struct {
Code string `json:"code"`
Title string `json:"title"`
RequestID string `json:"request_id"`
Candidates []struct {
Code string `json:"code"`
} `json:"candidates"`
}
// Track은 내용이 그대로면 (nil, etag, nil)을 돌려준다. 운송장이 없으면 ErrNotFound다.
func (c *Client) Track(number, carrier, etag string) (map[string]any, string, error) {
u := fmt.Sprintf("%s/tracking/%s", base, url.PathEscape(number))
if carrier != "" {
u += "?carrier=" + url.QueryEscape(carrier)
}
req, _ := http.NewRequest(http.MethodGet, u, nil)
req.Header.Set("Authorization", "Bearer "+c.APIKey)
if etag != "" {
req.Header.Set("If-None-Match", etag)
}
client := c.HTTP
if client == nil {
client = &http.Client{Timeout: 15 * time.Second}
}
res, err := client.Do(req)
if err != nil {
return nil, "", err
}
defer res.Body.Close()
switch res.StatusCode {
case http.StatusNotModified: // 304. 쿼터 차감 없음
return nil, etag, nil
case http.StatusOK:
var out map[string]any
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
return nil, "", err
}
return out, res.Header.Get("ETag"), nil
}
var p Problem
if err := json.NewDecoder(res.Body).Decode(&p); err != nil {
return nil, "", err
}
var retryAfter time.Duration
if v, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil {
retryAfter = time.Duration(v) * time.Second
}
switch p.Code {
case "tracking_number_not_found":
return nil, "", ErrNotFound // 차감된다. 폴링을 멈춘다
case "carrier_not_detected":
if carrier == "" && len(p.Candidates) > 0 { // 후보로 한 번만 재시도
return c.Track(number, p.Candidates[0].Code, etag)
}
return nil, "", fmt.Errorf("%w (request_id=%s)", ErrNotDetected, p.RequestID)
case "quota_exceeded":
return nil, "", &RetryError{ErrQuotaExceeded, p.Title, retryAfter}
case "carrier_unavailable":
return nil, "", &RetryError{ErrCarrierUnavailable, p.Title, retryAfter}
case "carrier_error":
return nil, "", &RetryError{ErrCarrierError, p.Title + " (request_id=" + p.RequestID + ")", retryAfter}
default:
return nil, "", fmt.Errorf("%s: %s (request_id=%s)", p.Code, p.Title, p.RequestID)
}
}
// 사용 예. 실제로는 별도 main 패키지에서 taekbae를 import해서 쓰지만,
// 예시는 같은 파일에 둔다.
func main() {
c := &Client{APIKey: os.Getenv("TAEKBAE_API_KEY")}
body, etag, err := c.Track("12345678901", "cj", "")
switch {
case errors.Is(err, ErrNotFound):
fmt.Println("아직 등록되지 않은 운송장입니다") // 404를 '변화 없음'으로 읽지 않는다
case err != nil:
panic(err)
case body == nil:
fmt.Println("변화 없음", etag) // 304. 가지고 있던 etag를 그대로 쓴다
default:
fmt.Println(body["status"])
}
}
// Cargo.toml: reqwest = { version = "0.12", features = ["json"] }
// serde_json = "1" · tokio = { version = "1", features = ["full"] }
// thiserror = "2"
use reqwest::{Client, StatusCode, header};
use serde_json::Value;
const BASE: &str = "https://taekbae.net/v1";
#[derive(Debug, thiserror::Error)]
pub enum TrackError {
#[error("운송장 정보 없음")] // 404. 304(변화 없음)와 다르다. 차감되므로 폴링을 멈춘다
NotFound,
#[error("택배사 미확정 (request_id={0})")] // 422. 후보가 비었다
CarrierNotDetected(String),
#[error("택배사 장애: {title} ({retry_after}초 후 재시도)")] // 503. 운송장 없음과 다르다
CarrierUnavailable { title: String, retry_after: u64 },
#[error("택배사 응답 실패: {title} (request_id={request_id})")] // 502. 재시도 가능
CarrierError { title: String, request_id: String },
#[error("한도 초과, {0}초 후 재시도")]
QuotaExceeded(u64),
#[error("{code}: {title} (request_id={request_id})")]
Api { code: String, title: String, request_id: String },
#[error(transparent)]
Http(#[from] reqwest::Error),
}
/// 내용이 그대로면 `Ok(None)`이다. 가지고 있던 ETag를 그대로 쓰면 된다.
pub async fn track(
http: &Client,
api_key: &str,
number: &str,
carrier: Option<&str>,
etag: Option<&str>,
) -> Result<Option<(Value, Option<String>)>, TrackError> {
let mut req = http
.get(format!("{BASE}/tracking/{number}"))
.bearer_auth(api_key);
if let Some(c) = carrier {
req = req.query(&[("carrier", c)]);
}
if let Some(tag) = etag {
req = req.header(header::IF_NONE_MATCH, tag);
}
let res = req.send().await?;
let tag = res
.headers()
.get(header::ETAG)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
match res.status() {
StatusCode::NOT_MODIFIED => return Ok(None), // 쿼터 차감 없음
StatusCode::OK => return Ok(Some((res.json().await?, tag))),
_ => {}
}
let retry_after = res
.headers()
.get(header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
.unwrap_or(60);
let problem: Value = res.json().await?;
let code = problem["code"].as_str().unwrap_or_default();
let title = problem["title"].as_str().unwrap_or_default().to_string();
let request_id = problem["request_id"].as_str().unwrap_or_default().to_string();
match code {
"tracking_number_not_found" => Err(TrackError::NotFound),
"carrier_not_detected" => {
// 후보가 비어 있을 수 있다. 있으면 한 번만 재시도한다.
let first = problem["candidates"]
.get(0)
.and_then(|c| c["code"].as_str())
.map(str::to_string);
match first {
Some(c) if carrier.is_none() => {
Box::pin(track(http, api_key, number, Some(&c), etag)).await
}
_ => Err(TrackError::CarrierNotDetected(request_id)),
}
}
"carrier_unavailable" => Err(TrackError::CarrierUnavailable { title, retry_after }),
"carrier_error" => Err(TrackError::CarrierError { title, request_id }),
"quota_exceeded" => Err(TrackError::QuotaExceeded(retry_after)),
_ => Err(TrackError::Api {
code: code.to_string(),
title,
request_id,
}),
}
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let http = Client::new();
let api_key = std::env::var("TAEKBAE_API_KEY")?;
match track(&http, &api_key, "12345678901", Some("cj"), None).await {
Ok(Some((body, _etag))) => println!("{}", body["status"]["label"]),
Ok(None) => println!("변화 없음"), // 304. 가지고 있던 결과를 그대로 쓴다
Err(TrackError::NotFound) => println!("아직 등록되지 않은 운송장입니다"), // 404를 '변화 없음'으로 읽지 않는다
Err(e) => return Err(e.into()),
}
Ok(())
}
같은 운송장을 반복해서 조회해야 한다면(배송이 끝날 때까지 폴링) 배송 상태 계속 확인하기를 먼저 보세요.
모든 요청에 Authorization 헤더가 필요합니다.
Authorization: Bearer tb_xxxxxxxxxxxxxxxxxxxx
쿼리스트링으로는 키(KEY)를 받지 않습니다. URL에 실린 키는 액세스 로그·프록시 로그·Referer 헤더에 그대로 남기 때문입니다. 키가 노출됐다면 계정에서 즉시 폐기하고 새로 발급하세요.
API 키는 암호화해 저장합니다. API 관리 권한이 있는 프로젝트 구성원은 내 계정 › API 관리에서 키 전체를 다시 확인할 수 있으므로, 잃어버렸다고 바로 재발급할 필요는 없습니다. 다만 콘솔에 접근할 수 있는 사람은 키도 볼 수 있으니 구성원과 로그인 계정을 신중하게 관리하세요.
계정도 실 운송장번호도 필요 없습니다. 아래 고정 키(KEY)로 호출하면 지어낸 배송 이력을 즉시 돌려줍니다. 발급 절차가 없는 키라 쿼터를 차감하지 않고, 택배사도 실제로 부르지 않습니다.
curl https://taekbae.net/v1/tracking/CA123456789KR \
-H "Authorization: Bearer tb_test_sandbox"
# Windows PowerShell 5.1 / PowerShell 7+
Invoke-RestMethod -Uri "https://taekbae.net/v1/tracking/CA123456789KR" `
-Headers @{ Authorization = "Bearer tb_test_sandbox" } | ConvertTo-Json -Depth 6
tb_test_sandbox는 그대로 복사해서 쓰는 고정 문자열입니다. 콘솔에서 발급받지 않습니다. 이 키는 GET /v1/tracking/<운송장번호> 조회에만 동작합니다. /v1/carriers·웹훅 같은 다른 엔드포인트는 콘솔에서 발급한 실제 키가 필요합니다.
버튼을 누르면 위와 같은 요청을 이 브라우저가 대신 보냅니다. curl도 PowerShell도 설치할 필요가 없습니다. 아래 표의 번호로 바꿔 넣으면 오류 응답도 눌러서 재현할 수 있습니다.
| 운송장번호 | 응답 |
|---|---|
| 아래 표에 없는, 형식만 맞는 아무 번호 | 200 배송출발 (?eta=true면 도착 예측도 함께 옵니다) |
CA123456789DE | 200 배송완료 (status.is_final: true) |
CA123456789RT | 200 반송 (status.is_final: true) |
CA123456789NF | 404 tracking_number_not_found |
CA123456789CD | 422 carrier_not_detected(후보 3곳 동봉) |
CA123456789CU | 503 carrier_unavailable(Retry-After 포함) |
CA123456789QE | 429 quota_exceeded |
재시도·백오프 코드를 짜고 있다면 오류 픽스처 쪽이 더 유용합니다. 실 운송장으로는 이 응답들을 마음대로 재현할 방법이 없습니다.
실제 발급 키(tb_...)는 뒤에 무작위 문자열이 오므로 이 고정 문자열과 절대 겹치지 않습니다. 준비가 되면 위 요청의 Authorization 값만 발급받은 키로 바꿔 끼우세요. 나머지 코드는 그대로 씁니다.
carrier를 생략하면 운송장번호만으로 택배사를 추론합니다. 국제 운송장은 접두어가 결정적이고(EMS의 S10 형식, 해외직구의 LP…CN), 국내 번호는 학습된 형식 패턴을 씁니다.
curl https://taekbae.net/v1/tracking/12345678901 \
-H "Authorization: Bearer $TAEKBAE_API_KEY"
조회에 성공하면 응답의 carrier.detected가 true로 옵니다. 우리가 추론한 값이라는 뜻입니다. 이 값으로 "자동 감지를 믿을지, 사용자에게 택배사를 물을지" 판단할 수 있습니다. 감지에 실패하면 아래처럼 422가 오므로, 두 경우를 모두 처리해야 합니다.
해외 배송은 번호가 국내 구간에서 바뀝니다.
EE…KR(EMS), LP…CN(해외직구) 같은 국제 형식은 접두어로 바로 판별되지만,
국내 인계 후 받는 번호는 그냥 국내 형식의 숫자라 국내 규칙을 탑니다.
해외 주문에서 받은 번호가 422로 돌아온다면 대개 이 경우이니,
후보 목록을 쓰거나 carrier를 지정하세요.
자동 감지 대상은 15개입니다. unsupported인
CU편의점택배·DHL 둘만 빠집니다. 다만 방식과 신뢰도가 셋으로 갈립니다.
reliable 다섯 곳은 학습된 번호 형식으로 추론하고, EMS·해외직구·투데이는
접두어로 1순위를 잡아 학습과 무관하며, 나머지 7개는
감지 대상이긴 하나 적중률을 잴 만큼의 표본이 아직 없습니다.
아래 적중률은 학습 방식에만 해당합니다(2026.09.02 실측, 정답 6,270건):
CJ 99.3%(n=2,689) · 롯데 99.8%(n=1,959) · 한진 99.8%(n=1,373) ·
우체국 97.5%(n=157) · 로젠 100%(n=58).
같은 날 결과 표(89,950건)로 재면 다섯 곳 모두 99.9% 이상이고
우체국·로젠은 표본이 각각 12,654건·4,699건으로 두꺼워집니다. 다만 그쪽은
대형 판매자 한 곳의 번호 블록이 절반을 차지해 실제보다 쉽습니다.
아래 표에는 낮게 나온 쪽을 싣습니다.
| 등급 | 대상 | 1순위 적중률 |
|---|---|---|
reliable |
CJ대한통운 · 한진택배 · 롯데택배 · 로젠택배 · 우체국택배 | 97.5 ~ 100% |
prefix |
투데이(To-Day) · 알리익스프레스 · 우체국 EMS | 국제 형식이면 1순위 확정 (학습 무관)* |
weak |
경동택배 · 대신택배 · SLX택배 · GS25반값택배 · 천일택배 · 일양로지스 · 쿠팡(CLS) | 정답 표본 없다시피 (학습 9~789건) |
unsupported |
CU편의점택배 · DHL | 감지 대상 아님 |
자동 감지는 안전망으로 쓰세요.
택배사를 아신다면 carrier를 지정하세요.
학습 표본의 99.7%가 reliable 다섯 곳에 몰려 있어서, 소형 택배사 물량이 많다면
자동 감지에 의존하지 마세요.
감지에 실패해도 다른 택배사로 대신 조회하지 않습니다. 실측에서 2순위 후보가 결과를 구해낸 건이 0건이었습니다. 1순위가 빗나가는 경우는 대개 그 번호가 어디에도 없는 것이라 두 번째도 똑같이 실패합니다. 재시도는 정확도를 못 올리면서 지연만 두 배로 만들기 때문에, 후보 목록을 돌려주고 재시도 여부는 호출자가 정하도록 했습니다.
* 접두어는 1순위를 정할 뿐, 조회 성공을 보장하지 않습니다. 국제 구간을 지난 화물이 국내에서 우체국으로 인계되는 경우가 있어, 접두어로 잡은 1순위(EE…KR→우체국 EMS, LP…CN→알리익스프레스)가 정보를 못 내놓으면 우체국택배가 후보로 돌아옵니다. 즉 국제 형식 번호라도 상류에 없으면 404가 아니라 422(tried 1개 + candidates 1개)가 올 수 있습니다.
등급은 GET /v1/carriers의 auto_detect로도 받을 수 있습니다. 택배사별 표에서 확인하세요.
특정하지 못하면 후보와 함께 422가 옵니다. 막다른 오류가 아니라 다음 행동이 있는 오류입니다. 17개 전체 목록 대신 남은 후보만 보여주면 됩니다.
{
"type": "https://taekbae.net/docs/api/errors/carrier_not_detected",
"title": "운송장번호로 택배사를 특정하지 못했습니다. carrier 파라미터로 지정해 주세요",
"code": "carrier_not_detected",
"status": 422,
"detected": true,
"tried": [ { "code": "cj", "name": "CJ대한통운" } ],
"candidates": [ { "code": "lotte", "name": "롯데택배" },
{ "code": "hanjin", "name": "한진택배" } ],
"request_id": "req_a91a852649404caea0c13bfd4943182f"
}
candidates는 아직 시도하지 않은 후보만 담고, 빌 수 있습니다.
tried는 이번 요청에서 실제로 조회했으나 정보가 없었던 택배사라
같은 값으로 재시도하면 반드시 404가 납니다.
후보를 다 썼거나 감지 모델이 후보를 하나도 못 뽑으면 candidates는
[]이니, 인덱싱 전에 길이를 확인하세요.
그때는 택배사 목록에서 사용자가 직접 고르게 하는 것이 유일한 다음 행동입니다.
목록에 아예 없는 택배사도 있습니다.
ECMS가 그렇습니다.
번호 형식으로 어디 것인지는 알아보지만
GET /v1/carriers 목록에 없고, carrier에 넣을 코드도 없습니다.
그래서 carrier 없이 넣으면 위와 같은 422가 오고,
택배사 목록에서 고르게 해도 고를 것이 없습니다.
번호가 잘못된 것이 아닙니다. 저희가 그 택배사를 조회하지 못하는 것뿐입니다. 이용자에게 "운송장번호를 확인하세요"라고 안내하면 틀린 말이 되고, 이용자는 맞는 번호를 계속 다시 넣게 됩니다. 택배넷 웹 화면은 이때 어느 회사 번호인지 알려주고 그 회사 공식 조회 페이지로 보냅니다 (ECMS → https://ese.ecmsglobal.com/#/ecms/tracking?order_number={tracking_number}).
지금 /v1은 그 안내를 응답에 담지 않습니다.
응답만 보고는 "우리가 못 찾은 것"과 "우리가 조회하지 않는 택배사"를 구분할 수 없습니다.
같은 안내가 필요하면 지금은 직접 처리하셔야 합니다.
자동 감지 조회도 호출은 1건입니다. 택배사를 실제로 부른 이상 결과가 200이든 422든 똑같이 1건을 셉니다. 같은 크롤이 carrier 생략 여부로 공짜가 되면 안 되니까요. 반대로 후보를 하나도 못 뽑아 택배사를 아예 부르지 않은 422(tried: [])는 차감하지 않습니다.
tried: []가 두 가지 뜻이라는 점만 주의하세요. 같은 번호를 방금 전탐색해 어디에도 없다고 확인했다면, 그 결과를 잠시 기억해 두고 다음 요청에서 택배사를 다시 부르지 않습니다. 이때도 tried는 []이고 차감도 없지만 직전에 택배사를 전부 훑은 뒤라 다음 행동이 다릅니다(후보 지정이 아니라 잠시 뒤 재시도). 두 경우는 응답의 cached로 가릅니다. 오류의 필드 표를 보세요.
쇼핑몰에서 판매자가 운송장번호를 등록했지만 실제로는 택배사에 물건을 넘기지 않은 경우를 잡는 패턴입니다. 배송 완료까지 추적할 필요 없이 "택배사가 물건을 인수했는가" 하나만 보면 됩니다.
events[].stage를 봅니다. status.code만 보면 안 됩니다.
그 값은 지금 상태라, 이미 간선으로 넘어간 건은
linehaul이지 picked_up이 아닙니다.
"집화를 지났는가"는 이력 전체에서 찾아야 합니다.
stage | 뜻 | 인수됐나 |
|---|---|---|
accepted | 접수. 운송장은 발급됐지만 아직 물건을 받지 않은 상태 | 아니오 |
picked_up | 집화완료. 택배사가 물건을 실제로 인수 | 예 |
at_origin_terminal | 출발지 터미널 도착 | 예 |
linehaul | 간선 이동 중 | 예 |
at_destination_terminal | 도착지 터미널 도착 | 예 |
out_for_delivery | 배송출발 | 예 |
delivered | 배송완료 | 예 |
exception | 배송 이상(주소불명 등). 물건은 이미 택배사에 있습니다 | 예 |
returned | 반송. 인수 후 되돌아간 것입니다 | 예 |
cancelled | 취소. 인수 전 취소인지 후인지 구분되지 않습니다 | 판단 보류 |
unknown | 문구를 단계로 옮기지 못함 | 판단 보류 |
accepted만 있고 그 뒤가 없는 상태가 며칠째 그대로면 가송장을 의심할 신호입니다.
다만 접수 당일·주말·연휴에는 정상 건도 그렇게 보이므로 경과 시간을 함께 보세요.
위 판정을 서버가 대신 합니다. 조회 응답에 pickup_pending이 실리면
「접수만 있고 집화가 없는 채로 영업일 문턱을 넘었다」는 뜻입니다. 넘지 않았으면 키 자체가 없습니다.
{
"status": { "code": "accepted", "label": "접수", "is_final": false },
"pickup_pending": { "business_days": 3 }
}
경과는 영업일로 셉니다. 주말·공휴일·그 택배사의 휴무일을 빼고 세기 때문에, 금요일 오후에 접수된 건이 월요일까지 조용해도 신호가 뜨지 않습니다. 그건 정상이니까요. 택배사마다 쉬는 날이 다른 것도 반영합니다.
취소되거나 단계를 판정하지 못한 이벤트가 섞인 건에는 붙지 않습니다.
취소는 인수 전인지 후인지 구분되지 않아서, 붙이면 취소된 건마다 「집화가 안 됐다」가 나갑니다.
business_days가 함께 오므로 필요하면 그 값으로 각자 문턱을 다시 잡을 수 있습니다.
웹훅을 쓰면 이 순간에 tracking.pickup_pending 콜백이 한 번 옵니다.
가장 강한 신호는 404 tracking_number_not_found입니다.
택배사 전산에 그 번호가 아예 없다는 뜻이라, 만들어 낸 번호이거나
다른 택배사 번호를 잘못 등록한 경우입니다.
422 carrier_not_detected(자동 감지 실패)와는 다릅니다.
그건 우리가 택배사를 못 고른 것이니
carrier를 지정해 다시 물어야 합니다.
events=latest로 가볍게 조회합니다. 다만 최신 이벤트 하나만 오므로
이미 지나간 picked_up이 안 보일 수 있습니다.
인수 여부를 처음 확정할 때는 events=full로 한 번 받고,
확정된 뒤의 추적에만 latest를 쓰세요.
If-None-Match로 조건부 요청을 보냅니다. 내용이 그대로면
304가 오고 쿼터가 차감되지 않습니다.
하루 수천 건을 주기적으로 조회하는 서비스에서는 이 방법이 쿼터를 가장 많이 아껴줍니다.
(캐시와 폴링)
status.is_final까지 갈 필요가 없습니다.
429 · 503은 Retry-After를 지켜 재시도합니다.
502 carrier_error는 쿼터를 차감하지 않으므로 바로 재시도해도 됩니다.
# "이미 인수됨"을 뜻하는 단계를 전부 나열하지 않는다. 그러면 나중에
# 택배사가 새 단계를 추가할 때마다 이 목록도 같이 고쳐야 하고, 고치는 걸
# 잊으면 이미 인수된 건이 "아직 접수만 됨"으로 잘못 판정된다. 대신 "아직
# 인수 전"과 "판단 불가"만 좁게 정의하고, 그 밖의 모든 단계는 인수된 것으로
# 본다. 모르는 새 단계가 와도 안전한 쪽(인수됨)으로 기본 처리된다.
NOT_YET_STAGES = {"accepted"} # 접수만 되고 아직 인수 전
UNCLEAR_STAGES = {"cancelled", "unknown"} # 인수 전/후를 구분할 수 없음
def pickup_state(body):
"""('picked_up' | 'not_yet' | 'unclear', 집화 시각 or None)"""
# status.code가 아니라 이력 전체를 본다. 이미 간선으로 넘어간 건은
# 지금 상태가 linehaul이라 picked_up과 절대 같지 않다.
events = body.get("events", [])
stages = {e.get("stage") for e in events}
if not stages:
return "unclear", None
if stages <= NOT_YET_STAGES:
return "not_yet", None
if stages <= NOT_YET_STAGES | UNCLEAR_STAGES:
return "unclear", None
for event in events:
if event.get("stage") not in NOT_YET_STAGES | UNCLEAR_STAGES:
# 시각을 안 준 이벤트에는 occurred_at 키가 없다(date_only·missing).
at = event.get("occurred_at")
return "picked_up", at
return "unclear", None
이 판정은 "택배사 전산에 인수 기록이 있다"까지만 말해 줍니다. 빈 상자를 접수한 경우처럼 전산상 정상이지만 실물이 다른 건은 이 API로 알 수 없습니다. 가송장 판정의 한 축으로 쓰고, 최종 판단은 다른 근거와 함께 하세요.
시트에 운송장 열이 있다면 셀 함수 하나로 배송상태를 꽂을 수 있습니다. 코드를 짜지 않고, 파일을 올리지도 않습니다.
=TAEKBAE(A2:A200) 운송장 범위 → 배송상태
=TAEKBAE(A2:A200, B2:B200) 운송장 + 택배사 범위(택배사를 아는 경우)
=TAEKBAE_ETA(A2:A200) 도착 예상
=TAEKBAE_ALL(A2:A200) 배송상태·현재위치·처리일시·도착예정 4열
IMPORTXML 로는 안 되나
택배사 조회 페이지를 IMPORTXML 로 직접 긁는 방법이 널리 돌아다니는데,
오래 못 갑니다. 조회 결과가 자바스크립트로 그려지는 택배사가 많아 애초에
안 잡히고, 잡히는 곳도 마크업이 한 번 바뀌면 XPath 가 죽습니다. 택배사마다 다른 그 층을
유지하는 것이 이 API 가 하는 일입니다.
TAEKBAE_API_KEY 를 추가하고
콘솔에서 발급한 키(KEY)를 넣습니다.=TAEKBAE(A2:A10) 을 넣어 봅니다.키(KEY)를 시트 셀에 적지 마세요. 시트를 공유하면 키가 같이 나갑니다. 스크립트 속성에 넣으면 편집 권한이 있는 사람에게만 보이고, 함수 인자로도 넘기지 않습니다.
시트 함수는 30초 안에 끝나야 해서 수백 행에서는 #ERROR! 로 끝납니다.
붙여넣은 코드가 시트 메뉴에 「택배넷 → 선택 범위 갱신」을 같이 만듭니다.
운송장 열을 고르고 실행하면 옆 4열에 결과를 값으로 써 넣습니다.
30초 제한이 없고, 시트를 고칠 때마다 다시 조회하지도 않습니다.
한 요청에 50건까지 담깁니다(POST /v1/tracking:batch 의 상한).
붙여넣은 코드가 알아서 50건씩 나눠 보내고, 같은 운송장은 5분 동안 다시 묻지 않습니다.
시트는 아무 편집에도 함수를 다시 돌리기 때문에 이 캐시가 없으면 쿼터가 그냥 녹습니다.
조회가 실패한 행은 캐시에 남기지 않아 다음 갱신에서 다시 시도합니다.
/**
* 택배넷 구글 스프레드시트 연동 (Apps Script)
*
* 시트에 함수로 배송상태를 꽂습니다.
*
* =TAEKBAE(A2:A200) 운송장 범위 → 배송상태
* =TAEKBAE(A2:A200, B2:B200) 운송장 + 택배사 범위(택배사를 아는 경우)
* =TAEKBAE_ETA(A2:A200) 도착 예상
* =TAEKBAE_ALL(A2:A200) 배송상태·현재위치·처리일시·도착예정 4열
*
* 붙이는 법
* 1. 시트에서 확장 프로그램 → Apps Script
* 2. 이 파일 내용을 통째로 붙여넣고 저장
* 3. 프로젝트 설정 → 스크립트 속성에 TAEKBAE_API_KEY 를 추가
* ★ 키를 시트 셀에 적지 마세요. 시트를 공유하면 키가 같이 나갑니다.
* 4. 시트로 돌아와 =TAEKBAE(A2:A10) 을 넣어 봅니다
*
* 행이 많으면(수백 줄) 함수 대신 메뉴를 쓰세요 —
* 「택배넷 → 선택 범위 갱신」은 30초 제한이 없습니다(아래 onOpen).
*/
var TAEKBAE_BASE = 'https://taekbae.net/v1';
// 한 요청에 담을 수 있는 운송장 수. 서버 상한이 50 이라 이 값을 올리면 400 이 됩니다.
var TAEKBAE_CHUNK = 50;
// 같은 운송장을 다시 묻지 않는 시간(초). 시트는 아무 편집에도 함수를 다시 돌리므로
// 이게 없으면 한 번 고칠 때마다 전 행을 재조회합니다.
var TAEKBAE_CACHE_SECONDS = 300;
function taekbaeApiKey_() {
var key = PropertiesService.getScriptProperties().getProperty('TAEKBAE_API_KEY');
if (!key) {
throw new Error('스크립트 속성에 TAEKBAE_API_KEY 가 없습니다. 프로젝트 설정에서 추가해 주세요.');
}
return key;
}
// 범위·단일 셀·배열을 모두 한 줄짜리 문자열 배열로 편다.
function taekbaeFlatten_(value) {
if (value === null || value === undefined) return [];
if (!Array.isArray(value)) return [String(value).trim()];
var out = [];
for (var i = 0; i < value.length; i++) {
var row = value[i];
if (Array.isArray(row)) {
for (var j = 0; j < row.length; j++) out.push(String(row[j] === null || row[j] === undefined ? '' : row[j]).trim());
} else {
out.push(String(row === null || row === undefined ? '' : row).trim());
}
}
return out;
}
// 서버와 같은 규칙으로 운송장번호를 표준형으로 만든다(하이픈 제거 + 대문자).
function taekbaeCanonical_(number) {
return String(number || '').replace(/-/g, '').trim().toUpperCase();
}
function taekbaeCacheKey_(number, carrier) {
return 'tb:' + taekbaeCanonical_(number) + ':' + String(carrier || '');
}
/**
* 운송장 목록을 조회해 번호 → 결과 객체 맵으로 돌려준다.
*
* 캐시에 있는 것은 건너뛰고, 남은 것만 50건씩 묶어 POST /v1/tracking:batch 로 보낸다.
* 시트 함수는 재계산 때 수백 셀이 한꺼번에 터지므로 **묶어 보내는 것이 핵심**이다 —
* 단건 조회로 돌리면 쿼터를 그대로 맞고 #ERROR! 밭이 된다.
*/
function taekbaeLookup_(numbers, carriers) {
var cache = CacheService.getScriptCache();
var results = {};
var pending = [];
var seen = {};
for (var i = 0; i < numbers.length; i++) {
var number = numbers[i];
if (!number) continue;
var carrier = carriers && carriers[i] ? carriers[i] : '';
var key = taekbaeCacheKey_(number, carrier);
if (seen[key]) continue;
seen[key] = true;
var hit = cache.get(key);
if (hit) {
try {
results[key] = JSON.parse(hit);
continue;
} catch (error) {
// 캐시가 깨졌으면 다시 묻는다.
}
}
pending.push({ key: key, number: number, carrier: carrier });
}
for (var start = 0; start < pending.length; start += TAEKBAE_CHUNK) {
var chunk = pending.slice(start, start + TAEKBAE_CHUNK);
var items = chunk.map(function (entry) {
var item = { tracking_number: entry.number, eta: true };
if (entry.carrier) item.carrier = entry.carrier;
return item;
});
var response = UrlFetchApp.fetch(TAEKBAE_BASE + '/tracking:batch', {
method: 'post',
contentType: 'application/json',
headers: { Authorization: 'Bearer ' + taekbaeApiKey_() },
payload: JSON.stringify({ items: items }),
muteHttpExceptions: true
});
var code = response.getResponseCode();
var body = {};
try {
body = JSON.parse(response.getContentText() || '{}');
} catch (error) {
body = {};
}
if (code !== 200) {
// 배치 전체가 거절된 경우(인증·한도). 이 묶음의 모든 행에 같은 사유를 남긴다.
var reason = body.title || ('HTTP ' + code);
for (var r = 0; r < chunk.length; r++) results[chunk[r].key] = { error: reason };
continue;
}
var rows = Array.isArray(body.results) ? body.results : [];
for (var n = 0; n < chunk.length; n++) {
var row = rows[n] || {};
// 실패 항목은 problem 객체로 온다(HTTP 는 200 이다). 성공 응답의 `status` 는
// 객체이고 problem 의 `status` 는 숫자라, 그 모양으로 가른다.
var isProblem = row.title && (typeof row.status !== 'object' || row.status === null);
var value = isProblem ? { error: row.title } : row;
results[chunk[n].key] = value;
cache.put(chunk[n].key, JSON.stringify(value), TAEKBAE_CACHE_SECONDS);
}
}
return results;
}
// 최근 이력. events 는 시간순이라 마지막 항목이 지금 상태다.
function taekbaeLastEvent_(result) {
if (!result || !Array.isArray(result.events) || result.events.length === 0) return null;
return result.events[result.events.length - 1];
}
// 「2026-07-28 20:59」처럼 시트에서 읽히는 꼴로 자른다. ISO 문자열을 그대로 넣으면
// 시트가 텍스트로 잡아 열이 지저분해진다.
function taekbaeShortTime_(iso) {
var match = String(iso || '').match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!match) return String(iso || '');
return match[1] + '-' + match[2] + '-' + match[3] + ' ' + match[4] + ':' + match[5];
}
/**
* 응답에서 열 하나에 넣을 값을 뽑는다.
*
* 필드 이름은 `/v1` 계약을 그대로 따른다 — 배송상태는 `status.label`,
* 위치는 `events[마지막].location`, 시각은 `last_event_at_raw`,
* 도착 예상은 `eta.estimated_delivery_to` 다. 화면 표(`current_location`·
* `process_time`)와 이름이 다르므로 그쪽을 베껴 쓰면 전부 빈 칸이 된다.
*/
function taekbaeField_(result, field) {
if (!result) return '';
if (result.error) return result.error;
if (field === 'status') {
if (result.status && result.status.label) return result.status.label;
if (result.status && result.status.code) return result.status.code;
return '';
}
if (field === 'location') {
var event = taekbaeLastEvent_(result);
return (event && event.location) || '';
}
if (field === 'time') {
// 택배사 원문이 있으면 그걸 쓴다(정밀도 손실이 없다).
if (result.last_event_at_raw) return result.last_event_at_raw;
return taekbaeShortTime_(result.last_event_at);
}
if (field === 'eta') {
var eta = result.eta;
// `eta` 키는 예측이 없으면 **아예 빠진다**(null 이 아니다) — 이 API 의 규칙이다.
if (!eta || eta.status !== 'available') return '';
if (eta.estimated_delivery_to) return taekbaeShortTime_(eta.estimated_delivery_to);
if (eta.estimated_delivery_from) return taekbaeShortTime_(eta.estimated_delivery_from);
return '';
}
return '';
}
function taekbaeColumns_(numbers, carriers, fields) {
var results = taekbaeLookup_(numbers, carriers);
var out = [];
for (var i = 0; i < numbers.length; i++) {
var number = numbers[i];
if (!number) {
out.push(fields.map(function () { return ''; }));
continue;
}
var carrier = carriers && carriers[i] ? carriers[i] : '';
var result = results[taekbaeCacheKey_(number, carrier)];
out.push(fields.map(function (field) { return taekbaeField_(result, field); }));
}
return out;
}
/**
* 배송상태.
*
* @param {A2:A200} trackingNumbers 운송장번호 범위 또는 셀
* @param {B2:B200} carriers 택배사 범위(생략하면 서버가 번호로 추정합니다)
* @return 배송상태
* @customfunction
*/
function TAEKBAE(trackingNumbers, carriers) {
var numbers = taekbaeFlatten_(trackingNumbers);
var carrierList = taekbaeFlatten_(carriers);
return taekbaeColumns_(numbers, carrierList, ['status']);
}
/**
* 도착 예상.
*
* @param {A2:A200} trackingNumbers 운송장번호 범위 또는 셀
* @param {B2:B200} carriers 택배사 범위(생략 가능)
* @return 도착 예상
* @customfunction
*/
function TAEKBAE_ETA(trackingNumbers, carriers) {
var numbers = taekbaeFlatten_(trackingNumbers);
var carrierList = taekbaeFlatten_(carriers);
return taekbaeColumns_(numbers, carrierList, ['eta']);
}
/**
* 배송상태·현재위치·처리일시·도착예정 4열.
*
* @param {A2:A200} trackingNumbers 운송장번호 범위 또는 셀
* @param {B2:B200} carriers 택배사 범위(생략 가능)
* @return 4열
* @customfunction
*/
function TAEKBAE_ALL(trackingNumbers, carriers) {
var numbers = taekbaeFlatten_(trackingNumbers);
var carrierList = taekbaeFlatten_(carriers);
return taekbaeColumns_(numbers, carrierList, ['status', 'location', 'time', 'eta']);
}
// ── 메뉴 경로 ────────────────────────────────────────────────────────────────
// 시트 함수는 30초 제한이 있어 수백 행에서 #ERROR! 로 끝납니다. 메뉴로 돌리면
// 6분까지 쓸 수 있고, 결과를 셀에 **값으로** 써서 재계산 때 다시 조회하지 않습니다.
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('택배넷')
.addItem('선택 범위 갱신 (운송장 열을 고르고 실행)', 'taekbaeRefreshSelection')
.addToUi();
}
function taekbaeRefreshSelection() {
var sheet = SpreadsheetApp.getActiveSheet();
var range = sheet.getActiveRange();
var values = range.getValues();
var numbers = [];
for (var i = 0; i < values.length; i++) numbers.push(String(values[i][0] || '').trim());
var rows = taekbaeColumns_(numbers, [], ['status', 'location', 'time', 'eta']);
sheet.getRange(range.getRow(), range.getColumn() + 1, rows.length, 4).setValues(rows);
SpreadsheetApp.getUi().alert(numbers.filter(String).length + '건을 갱신했습니다.');
}
한 번 조회하고 끝나는 게 아니라, 배송이 끝날 때까지 같은 운송장번호를 주기적으로 다시 물어봐야 하는 경우입니다. 이때만 아래 세 가지를 신경 쓰면 됩니다.
폴링은 status.is_final로 멈추세요. 문자열을 "배송완료"와 비교하는 코드는 반송·취소 건에서 영원히 돕니다. 그리고 매 요청에 ETag를 실으면 변화가 없을 때 304가 오고 쿼터가 차감되지 않습니다. 자세한 사용법은 캐시와 폴링을 보세요.
304와 404를 같은 값으로 뭉개지 마세요. 304는 "가진 결과가 아직 유효하다", 404는 "택배사에 그 운송장이 없다"입니다. 둘을 함께 null로 돌려주면 존재하지 않는 운송장이 화면에 '아직 변화 없음'으로 뜨고, 404는 차감 대상이라 폴링이 멈추지 않은 채 한도만 깎입니다.
이 세 가지를 실제로 구현한 코드는 코드로 붙이기의 각 언어
예제에 이미 들어 있습니다. track() 함수가 is_final · ETag · 404를
전부 처리하니, 폴링 루프에서 그 함수를 그대로 반복 호출하면 됩니다.
앞 절처럼 직접 폴링하는 대신, 운송장을 등록해 두면 상태가 바뀔 때마다 저희가 알려 드립니다. 배송완료 알림톡을 보내거나 주문 상태를 자동으로 바꾸는 일이라면 이쪽이 훨씬 간단합니다. 스케줄러도, 큐도, "지난번과 달라졌나"를 비교하는 코드도 필요 없습니다.
curl -X POST https://taekbae.net/v1/webhooks \
-H "Authorization: Bearer $TAEKBAE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/taekbae",
"items": [
{ "tracking_number": "CA123456789KR", "client_id": "order-1001" }
],
"metadata": { "shop": "my-store" }
}'
응답의 secret을 저장하세요. 이 응답에만 실립니다.
콜백이 진짜 저희가 보낸 것인지 확인하는 데 씁니다.
본문의 data는 배송 조회 응답과
완전히 같은 모양입니다. 이미 조회를 붙였다면 그 파서를 그대로 씁니다.
{
"id": "whev_7c1b2a3d4e5f60718293a4b5c6d7e8f9",
"event": "tracking.updated",
"subscription_id": "whs_9f2c1e5b7a4d4a0f8b1c2d3e4f5a6b7c",
"sent_at": "2026-08-14T09:12:31+09:00",
"client_id": "order-1001",
"previous_status": "linehaul",
"metadata": {
"shop": "my-store"
},
"data": {
"tracking_number": "12345678901",
"carrier": {
"code": "cj",
"name": "CJ대한통운",
"detected": false
},
"status": {
"code": "out_for_delivery",
"label": "배송출발",
"is_final": false
},
"last_event_at": "2026-08-14T09:12:00+09:00",
"last_event_at_raw": "2026-08-14 09:12:00",
"last_event_at_precision": "exact",
"events": [
{
"occurred_at": "2026-08-14T09:12:00+09:00",
"occurred_at_raw": "2026-08-14 09:12:00",
"time_precision": "exact",
"location": "성남상대원",
"status_text": "배송출발",
"stage": "out_for_delivery"
}
],
"as_of": "2026-08-14T09:12:30+09:00"
}
}
접수만 있고 집화가 없는 채로 영업일이 지나면 event가 tracking.pickup_pending인 콜백이 한 번 옵니다. 판매자가 운송장만 발급하고 실제로는 택배사에 넘기지 않은 경우(가송장)를 잡는 신호이고, 상태가 안 바뀌어도 오는 유일한 콜백입니다. 정체된 화물은 새 스캔이 없어서 상태 변경 알림으로는 영원히 조용합니다.
경과는 주말·공휴일·택배사 휴무를 뺀 영업일로 셉니다. 금요일 접수 건이 월요일까지 조용한 것은 정상이라 그 사이에는 안 옵니다.
본문의 data.pickup_pending.business_days로 며칠째인지 알 수 있어, 필요하면 그 값으로 각자 문턱을 다시 잡을 수 있습니다.
분실·파손·주소불명처럼 사람이 바로 손을 써야 하는 사고는
event가 tracking.exception으로 옵니다. 평범한 배송 안내와
이름을 나눠 둔 이유가 그것이니, 이건 고객 알림이 아니라 상담 쪽으로 보내세요.
본문 안을 들여다보고 직접 갈라낼 필요가 없습니다.
배송완료·반송·취소에 닿으면 event가 tracking.completed인
콜백이 마지막으로 한 번 가고 구독이 끝납니다. 해지 요청을 따로 보내지 않아도 됩니다.
같은 콜백이 두 번 올 수 있습니다. 받는 쪽이 2xx를 못 주면 다시 보내는데,
그 사이 여러분 서버가 이미 처리했을 수도 있습니다. 그래서 이벤트마다
id를 붙입니다. 재시도로 몇 번을 오든 이 값은 그대로이고,
나중에 직접 다시 보내기를 요청해도 같습니다.
받는 쪽에서는 이 값을 저장해 두고, 이미 본 것이면 아무 일도 하지 말고
200만 답하세요. 이 처리를 빼면 재시도 한 번이 곧 알림톡 두 통입니다.
같은 값이 X-Webhook-Id 헤더로도 오니 본문을 읽기 전에 거를 수 있습니다.
previous_status에는 직전에 알려 드렸던 상태가 들어 있습니다.
무엇이 바뀌었는지 알아내려고 여러분 DB를 다시 뒤질 필요가 없습니다.
첫 콜백에는 이 항목이 없습니다.
이번 요청을 실제로 보낸 시각은 X-Webhook-Timestamp 헤더입니다.
서명이 묶는 것도 그 헤더 값이라, 재시도된 콜백도 서명은 매번 새로 계산됩니다.
콜백 URL은 인터넷에 열려 있으므로 누구나 흉내 낼 수 있습니다.
X-Webhook-Signature를 확인해야 진짜 저희가 보낸 것인지 압니다.
| 헤더 | 값 |
|---|---|
X-Webhook-Signature | sha256=<hex> 형식입니다. 아래 방식으로 계산한 HMAC-SHA256. 시크릿을 바꾸는 동안에는 공백으로 구분해 여러 개가 옵니다(하나만 맞으면 통과시키세요) |
X-Webhook-Timestamp | 서명에 쓰인 epoch 초 |
X-Webhook-Id | 이벤트 ID. 본문의 id와 같은 값이고, 중복 처리를 막는 열쇠입니다 |
import hmac, hashlib, time
SECRET = os.environ["TAEKBAE_WEBHOOK_SECRET"] # 등록 응답의 secret
def verify(raw_body: bytes, signature: str, timestamp: str) -> bool:
# 오래된 요청은 거른다. 서명이 유효해도 과거 콜백을 그대로 다시 보내는
# 공격이 가능하기 때문에, 시각을 함께 서명하고 여기서 창을 좁힌다.
if abs(time.time() - int(timestamp)) > 300:
return False
expected = "sha256=" + hmac.new(
SECRET.encode(),
timestamp.encode() + b"." + raw_body,
hashlib.sha256,
).hexdigest()
# 시크릿을 바꾸는 동안에는 서명이 공백으로 구분돼 여러 개 온다.
# 하나만 맞으면 통과다. 그래서 교체 타이밍을 맞추지 않아도 된다.
# 문자열 비교는 반드시 상수 시간으로. == 를 쓰면 앞자리부터 맞춰
# 서명을 알아낼 수 있다.
return any(hmac.compare_digest(expected, s) for s in signature.split(" "))
@app.post("/webhooks/taekbae")
def receive():
raw = request.get_data() # 파싱 전 원본 바이트로 검증해야 한다
if not verify(raw, request.headers.get("X-Webhook-Signature", ""),
request.headers.get("X-Webhook-Timestamp", "0")):
return "", 401
# 같은 이벤트가 두 번 올 수 있다. 이 줄이 없으면 재시도 한 번이
# 곧 알림 두 통이다. 이미 본 것이면 아무 일도 하지 말고 200.
event_id = request.headers.get("X-Webhook-Id", "")
if already_handled(event_id):
return "", 200
body = json.loads(raw)
order_id = body.get("client_id") # 등록할 때 넣은 주문번호
status = body["data"]["status"]["code"]
# 무거운 일은 큐에 넘기고 바로 답한다(10초 안에 응답해야 한다).
enqueue_update(order_id, status)
mark_handled(event_id)
return "", 200
배송 단계에 따라 다릅니다. 상태가 실제로 자주 바뀌는 구간만 촘촘하게 봅니다. 간선에 실려 있는 화물을 1시간마다 확인해 봐야 달라지는 게 없기 때문입니다.
| 상태 | 확인 주기 | |
|---|---|---|
out_for_delivery | 1시간 | 밤 10시~아침 7시는 쉽니다 (배달이 없는 시간대입니다) |
picked_up · linehaul · 터미널 · exception | 6시간 | 간선은 밤에도 봅니다. 상차·허브 도착이 새벽에 찍힙니다 |
accepted · unknown | 24시간 | 접수만 되고 스캔 전인 화물은 자주 봐도 그대로입니다 |
쿼터는 등록할 때 운송장 수만큼 한 번만 차감합니다. 이후 저희가 하는 조회와 콜백 발송은 차감하지 않습니다. 직접 폴링하는 것보다 거의 언제나 싸고, 코드도 적습니다.
10초 안에 2xx로 답하세요. 콜백 처리에 시간이 걸리면 큐에 넣고 먼저 답하는 편이 안전합니다. 2xx가 아니면 저희가 다시 보냅니다(1분에서 시작해 최대 1시간 간격).
연속으로 실패하면 구독이 멈춥니다. 그때 구독 상태가 suspended가 됩니다. 받는 쪽을 고친 뒤 POST /v1/webhooks/{subscription_id}/resume을 한 번 부르면 밀려 있던 콜백부터 다시 나갑니다. 새로 등록할 필요가 없습니다(구독 ID도 시크릿도 그대로입니다).
410 Gone으로 답하면 즉시 멈춥니다. "더는 보내지 마라"는 뜻으로 읽고 재시도하지 않습니다. 엔드포인트를 없앨 때 쓰세요.
429나 503에 Retry-After를 실어 주시면 그만큼 기다립니다.
콜백은 최신 상태를 담습니다. 재시도 중에 상태가 또 바뀌면 마지막 상태 하나만 갑니다. 모든 중간 단계가 빠짐없이 오는 것은 보장하지 않습니다. 상태 전이 하나하나가 필요하다면 data.events를 보세요. 이력은 전부 들어 있습니다.
GET
/v1/tracking/{tracking_number}
| 이름 | 위치 | 필수 | 설명 |
|---|---|---|---|
tracking_number | 경로 | 필수 | 영숫자 8~20자. 하이픈은 자동 제거하고 영문은 대문자로 정규화합니다. |
carrier | 쿼리 | 선택 | 택배사 코드. 생략하면 자동 감지합니다. 빈 값(carrier=)은 생략과 같게 처리합니다. |
events | 쿼리 | 선택 | full(기본) · latest · none. 폴링에는 latest가 가볍습니다. 대소문자는 무시합니다. |
eta BETA | 쿼리 | 선택 | 도착 예측 포함 여부. 기본 false(미포함)이라 eta 키가 아예 오지 않습니다. 켜려면 eta=true. 1/0, yes/no, on/off도 받습니다. |
client_id | 쿼리 | 선택 | 주문번호 같은 여러분 쪽 식별자. 응답의 client_id에 그대로 돌아옵니다. 운송장번호는 재발송·반품·택배사 교체로 갈리는 자연키가 아니라, 여러분 쪽 주문에 되붙이려면 이 값이 필요합니다. 최대 128자. 생략하거나 빈 값(client_id=)이면 응답에 키 자체가 없습니다. 저장되는 결과 행에도 남아 /v1/results·/v1/delays·/v1/delays/no-scan 이 같은 이름으로 돌려줍니다. 지연 큐를 주문번호로 되붙이는 방법입니다. 마지막으로 보낸 값이 남고, 안 보낸 조회는 지우지 않습니다. |
각 파라미터는 한 번만 보내세요. 같은 파라미터를 두 번 보내거나 위 목록으로 해석할 수 없는 값을 보내면 400 invalid_parameter로 거절하고, 응답의 parameter가 어느 파라미터인지 알려줍니다. 조용히 기본값으로 처리하지 않습니다.
값이 없는 필드는 null이 아니라 키 자체가 빠집니다. 아래 항상 표시가 없는 필드는 없을 수 있다고 보고 짜세요.
| 필드 | 타입 | 설명 |
|---|---|---|
tracking_number | string | 정규화된 운송장번호. 하이픈을 제거하고 영문을 대문자로 올린 값이라 보낸 값과 다를 수 있습니다. 대조할 때는 이 값을 쓰세요. 항상 |
client_id | string | 요청의 client_id를 그대로 돌려준 값입니다. 요청에 없으면 이 키 자체가 없습니다(빈 문자열이 아닙니다). |
carrier | object | 어느 택배사로 조회했는지. 항상 |
status | object | 지금 배송 상태를 정규화한 값. 항상 |
last_event_at | string | 가장 최근 이벤트의 발생 시각(RFC 3339). 택배사가 시각까지 준 이벤트에만 들어 있습니다(last_event_at_precision이 exact). 변화 감지에 쓰기 좋은 값입니다. |
last_event_at_raw | string | 그 이벤트의 시각 문자열 원문(예: 2026-07-16 --:--). last_event_at이 없을 때 날짜를 꺼내는 자리입니다. events=none으로 폴링해도 날짜는 잃지 않습니다. |
last_event_at_precision | string | exact(날짜+시각) · date_only(날짜만). exact일 때만 last_event_at이 옵니다. 없는 시각을 00:00으로 채워 보내지 않습니다. 시각이 있는 이벤트가 하나도 없으면 이 세 키가 다 같이 빠집니다(그래야 "시각을 모른다"와 "이벤트가 없다"가 구분됩니다). |
events | array | 배송 이력. events=none이어도 키는 항상 있고 빈 배열이 옵니다. 최신순이 아니라 발생 순(오래된 것부터)입니다. 항상 |
parties | object | 수하인·송하인. 주는 택배사에서만 옵니다. |
delivery_agent | object | 배송 담당자. 주는 택배사에서만 옵니다. 지원 표 |
eta BETA | object | 도착 예측. ?eta=true로 요청했을 때만 옵니다. |
as_of | string | 택배사에서 정보를 가져온 시각. 응답을 만든 시각이 아니라 데이터의 나이입니다. 캐시된 응답이면 과거일 수 있습니다. 항상 |
carrier| 필드 | 타입 | 설명 |
|---|---|---|
code | string | 택배사 코드(cj, hanjin …). 다음 조회에 carrier로 그대로 넣으면 자동 감지를 건너뜁니다. 전체 목록 항상 |
name | string | 한국어 표시명. 분기 조건으로 쓰지 마세요. 문구는 바뀔 수 있고 code는 유지됩니다. 항상 |
detected | boolean |
true면 우리가 추론한 택배사입니다(carrier를 생략한 경우). 추론이 틀릴 수 있으니, 사용자에게 보여주는 화면이라면 "다른 택배사인가요?"를 열어 둘 근거가 됩니다.
운송장번호는 택배사마다 따로 발급되는 값이라, 서로 다른 택배사가 우연히 같은 번호를 각자 실제 배송 건에 쓰고 있는 경우가 드물게 있습니다.
그러면 추론이 가리킨 택배사도 진짜 데이터를 돌려주기 때문에 조회 자체는 성공한 것처럼 보이지만, 그게 사용자가 찾던 화물이 아닐 수 있습니다. "결과 없음"과는 다른, 더 조용한 실패입니다.false면 요청에서 지정한 값을 그대로 돌려준 것입니다. 필드 자체가 빠지는 게 아니라 실제로 false 값이 옵니다.
항상
|
carrier.detected 두 경우 비교// carrier를 생략해 자동 감지됐을 때
"carrier": { "code": "cj", "name": "CJ대한통운", "detected": true }
// ?carrier=cj 로 직접 지정했을 때 (필드가 빠지지 않고 그대로 false로 옵니다)
"carrier": { "code": "cj", "name": "CJ대한통운", "detected": false }
status| 필드 | 타입 | 설명 |
|---|---|---|
code | string | 17개 택배사의 서로 다른 문구를 하나로 정규화한 값. 분기는 이 값으로 하세요. 전체 목록 항상 |
label | string | 한국어 표시 문구. 화면용이며 바뀔 수 있습니다. 항상 |
is_final | boolean | 더 이상 변하지 않는 상태(delivered · returned · cancelled). 폴링 종료 조건으로 쓰세요. 이걸 안 보면 배송이 끝난 건을 영원히 다시 조회하게 됩니다. 항상 |
events[]| 필드 | 타입 | 설명 |
|---|---|---|
stage | string | 이벤트 하나를 정규화한 단계. status.code와 같은 값 집합입니다. "언제 어느 단계를 지났나"를 찾을 때 보는 값입니다. 항상 |
status_text | string | 택배사가 준 원문 문구("간선상차", "집화처리" …). 정규화가 문구를 바꾸지 않습니다(화면용 공백·링크 문구·연락처만 걷어냅니다). 사람에게 보여줄 때 씁니다. |
occurred_at | string | 발생 시각(RFC 3339, KST 오프셋). 택배사가 시각까지 준 이벤트에만 들어 있습니다(time_precision이 exact). 없으면 날짜는 occurred_at_raw에 있습니다. |
occurred_at_raw | string | 택배사가 준 시각 문자열 원문. 우리 파싱을 의심할 때 대조용이고, occurred_at이 없을 때 날짜를 꺼내는 자리이기도 합니다. |
time_precision | string | exact(날짜+시각) · date_only(날짜만) · missing(시각 없음). exact일 때만 occurred_at이 옵니다. 없는 시각을 00:00으로 채워 보내지 않습니다. 항상 |
location | string | 취급 지점("대전HUB", "서울강남" …). 택배사 표기 그대로라 표준 코드가 아닙니다. |
country | string | ISO 3166-1 alpha-2. 국제특송에서만 채워집니다. |
parties · delivery_agent| 필드 | 타입 | 설명 |
|---|---|---|
parties.receiver.name | string | 수하인. 택배사가 이미 가린 값("홍*동")을 그대로 전달합니다. |
parties.receiver.masked | boolean | 위 값이 가려진 것인지. true면 전체 이름이 아니므로 대조 키로 쓰면 안 됩니다. |
parties.sender.* | object | 송하인. 구조는 receiver와 같습니다. |
delivery_agent.name | string | 배송 담당 기사명. |
delivery_agent.phone | string | 담당 기사 연락처. 주는 택배사에서만 옵니다. |
eta BETA ?eta=true 일 때만| 필드 | 타입 | 설명 |
|---|---|---|
status | string | available이면 예측이 나왔다는 뜻입니다. 그 외에는 아래 시각 필드가 비어 있을 수 있습니다. |
estimated_delivery_from | string | 가장 빠른 경우의 도착 시각입니다. 이쪽은 보정하지 않아서 실제로는 이보다 일찍 오는 경우가 제법 있습니다. 고객 안내에 쓰지 마세요. 다만 밤 10시~아침 8시처럼 실제로 배달이 없는 새벽 시각으로는 내려가지 않습니다. 계산상 그 시간대에 걸리면 배달이 시작되는 아침 8시(다음 배달일)로 맞춰서 보여드립니다. 택배사가 직접 알려준 시간대(fallback_level이 carrier_declared)는 예외로, 원래 안내 그대로 보여드립니다. |
estimated_delivery_to | string | 하나만 쓴다면 이쪽(늦은 쪽)입니다. 다만 얼마나 잘 맞는지는 택배사와 배송 단계에 따라 크게 다릅니다. 실제 도착이 이 시각을 넘긴 비율은 전체 평균 4% 정도인데, 근거가 약한 예측에서는 10%를 훌쩍 넘기도 합니다. 아래 confidence와 fallback_level을 꼭 함께 보세요. 지금은 CJ대한통운의 간선·집화 구간만 따로 보정해 두었고(넘긴 비율 약 3%), 다른 택배사·단계는 보정 전입니다. 한 가지 더 알아 두면 좋습니다. 배송이 막바지에 들어서면(기사님이 물건을 싣고 나갔거나, 받는 지역 터미널에 도착했으면) 도착 '시각'보다 도착 '날짜'가 훨씬 잘 맞습니다. 다만 이건 저희가 택배사·배송 단계별로 하나씩 확인해 본 결과라, 지금까지 확인된 구간은 CJ대한통운·한진택배의 배송출발, 우체국택배의 도착 터미널 도착뿐입니다. taekbae.net 화면도 딱 그 구간에서만 날짜를 먼저 보여줍니다. API 응답에는 지금 배송 단계가 들어 있지 않아 어느 구간인지 구분하실 수 없으니, 남은 시간으로 도착 날짜를 역산해 고객에게 안내하는 건 권하지 않습니다. 확인되지 않은 구간에서는 하루씩 어긋나는 경우가 흔합니다. 날짜가 꼭 필요하시면 문의해 주세요, API로도 내려 드리는 쪽을 검토하겠습니다. |
p50_remaining_minutes | integer | 남은 시간의 중앙값(분). 절반은 이보다 빨리, 절반은 늦게 도착했다는 뜻입니다. 택배사가 안내한 배달 시간대(fallback_level이 carrier_declared)에서 그 시간대가 이미 시작됐으면 이 값이 0으로 내려갑니다. 그래서 이 값으로 도착 시각이나 날짜를 되짚어 계산하면 실제보다 이르게 나올 수 있습니다. |
p90_remaining_minutes | integer | estimated_delivery_to까지 남은 시간(분)입니다. 같은 값을 시각 대신 분으로 표현한 것이라, 위와 같은 주의사항이 그대로 적용됩니다. |
confidence | string | high · medium · low. low면 고객에게 그대로 약속하지 마세요. medium 이상이면서 fallback_level이 courier_stage로 시작하는 경우가 가장 잘 맞습니다(넘긴 비율 2~4%). 반대로 fallback_level이 day_offset_으로 시작하거나 coarse_kin_prior이면 참고치로만 쓰세요. 절반 넘게 어긋나는 경우도 있습니다. |
support_count | integer | 이 예측의 근거가 된 과거 배송 건수. 작을수록 흔들립니다. |
fallback_level | string | 같은 구간 표본이 없어 더 성긴 사전분포로 물러났을 때만 채워집니다. 이 키가 있으면 그만큼 덜 구체적인 근거라는 신호입니다. |
model_version | string | 예측 모델 버전. 예측이 이상할 때 문의에 함께 적어 주세요. eta가 오면 항상 함께 옵니다. |
as_of | string | 이 예측을 계산한 시각. eta가 오면 항상 함께 옵니다. |
시각이 없는 이벤트가 있습니다. 택배사는 시각 없이 날짜만 주거나 "2026-07-16 --:--" 같은 자리표시자를 줍니다(접수 이벤트에 흔합니다). 그런 이벤트에는 occurred_at 키가 아예 없고 time_precision이 date_only입니다. 날짜는 occurred_at_raw에서 꺼내세요. 없는 시각을 00:00으로 채워 보내던 동작은 2026.08.15에 바로잡았습니다.
최상위 last_event_at도 같은 규칙입니다. 마지막 이벤트가 날짜만 있는 건이면 last_event_at 키가 없고 last_event_at_precision이 date_only, 날짜는 last_event_at_raw에 있습니다. 롯데는 접수 이벤트에 "--:--"를 쓰기 때문에, 접수 직후 아직 집화 스캔이 안 찍힌 화물은 이벤트가 그 하나뿐이라 여기 걸립니다. 조회가 가장 몰리는 구간이니 events=none으로 폴링한다면 last_event_at_precision을 먼저 보세요.
값이 없는 필드는 null이 아니라 키 자체가 빠집니다. "이 택배사는 지원하지 않음"과 "이번 건에 값이 없음"은 택배사 지원 표로 구분하세요.
17개 택배사의 서로 다른 문구를 하나의 값 집합으로 정규화합니다. 택배사별 분기를 짤 필요가 없습니다.
code | label | 종결 |
|---|---|---|
accepted |
접수 | 진행 중 |
picked_up |
집화완료 | 진행 중 |
at_origin_terminal |
출발지 터미널 | 진행 중 |
linehaul |
간선 이동 중 | 진행 중 |
at_destination_terminal |
도착지 터미널 | 진행 중 |
out_for_delivery |
배송출발 | 진행 중 |
delivered |
배송완료 | 종결 |
exception |
배송 이상 | 진행 중 |
returned |
반송 | 종결 |
cancelled |
취소 | 종결 |
unknown |
확인 불가 | 진행 중 |
is_final이 true인 상태에 도달하면 폴링을 멈추세요. 배송완료만 종결이 아닙니다. 반송·취소도 더 이상 변하지 않습니다.
GET
/v1/carriers
조회하려고 먼저 불러야 하는 엔드포인트가 아닙니다. 자동 감지가 있으니 코드를 몰라도 조회됩니다. 이 목록은 "이 택배사에서 왜 이 필드가 안 오는가"에 답하기 위한 것이고, 쿼터를 차감하지 않습니다.
택배사마다 code · name · auto_detect · supports · requires 다섯 개가 옵니다. 지원 여부는 불리언 필드가 아니라 배열의 원소로 표현합니다. carrier.delivery_agent 같은 키는 없습니다.
supports와 requires는 축이 다릅니다. supports는 조회 응답에 무엇이 오는가이고, requires는 부르기 전에 무엇을 갖춰야 하는가입니다. requires에 account_api_key가 있으면 그 택배사에서 직접 발급받은 키(KEY)를 비즈니스 작업공간의 연동 · 외부 택배사에 등록해야 조회됩니다.
{
"carriers": [
{ "code": "cj", "name": "CJ대한통운", "auto_detect": "reliable", "supports": ["events", "eta"], "requires": [] },
{ "code": "slx", "name": "SLX택배", "auto_detect": "weak", "supports": ["events", "eta", "delivery_agent"], "requires": [] },
{ "code": "dhl", "name": "DHL", "auto_detect": "unsupported", "supports": ["events", "eta"], "requires": ["account_api_key"], "daily_free_limit": 250 }
],
"guidance_carriers": [
{ "name": "ECMS", "grade": "guidance_only", "detection": "ECIKQXX + 숫자", "detail_url_template": "https://ese.ecmsglobal.com/#/ecms/tracking?order_number={tracking_number}" }
]
}
guidance_carriers는 우리가 조회하지 않는 택배사입니다. 번호 형식으로 어디 것인지는 알아보지만 carriers 목록에는 없고, carrier에 넣을 코드도 없습니다. 이 배열의 길이를 carriers에 더하지 마세요. 비는 때도 있는데, 배열이 비었다고 키가 사라지지는 않습니다. 항상 옵니다.
const byCode = Object.fromEntries(
(await res.json()).carriers.map((c) => [c.code, c])
);
byCode["slx"].supports.includes("delivery_agent"); // true
code | 택배사 | 자동 감지 | supports | requires |
|---|---|---|---|---|
cj |
CJ대한통운 | 신뢰 | events, eta |
- |
hanjin |
한진택배 | 신뢰 | events, eta |
- |
lotte |
롯데택배 | 신뢰 | events, eta |
- |
logen |
로젠택배 | 신뢰 | events, eta |
- |
epost |
우체국택배 | 신뢰 | events, eta |
- |
kyungdong |
경동택배 | 표본 부족 | events, eta |
- |
daesin |
대신택배 | 표본 부족 | events, eta |
- |
slx |
SLX택배 | 표본 부족 | events, eta, delivery_agent |
- |
gs25 |
GS25반값택배 | 표본 부족 | events, eta |
- |
chunil |
천일택배 | 표본 부족 | events, eta |
- |
ilyang |
일양로지스 | 표본 부족 | events, eta |
- |
coupang |
쿠팡(CLS) | 표본 부족 | events, eta |
- |
today |
투데이(To-Day) | 접두어 | events, eta |
- |
cu |
CU편의점택배 | 미지원 | events, eta |
- |
aliexpress |
알리익스프레스 | 접두어 | events, eta |
- |
dhl |
DHL | 미지원 | events, eta |
account_api_key 250건/일 |
ems |
우체국 EMS | 접두어 | events, eta |
- |
표본 부족은 자동 감지 대상이지만 학습 데이터가 적어 1순위를 못 맞힐 수 있다는 뜻입니다. 이 택배사들은 carrier를 지정하는 편이 확실합니다. supports에 delivery_agent가 없는 택배사는 원본 응답에 배송 담당자 항목 자체가 없어서, 조회 응답에도 그 키가 나오지 않습니다.
응답에 guidance_carriers 배열이 같이 옵니다. 이 배열의 길이를 carriers에 더하지 마세요. "지원 택배사 N개"는 carriers 쪽만 셉니다.
| 택배사 | 등급 | 감지 | 응답의 carrier.code | 상세 |
|---|---|---|---|---|
| ECMS | 안내 전용 | `ECIKQXX` + 숫자(2026-08-07 실측 2건, 16자). 접두어는 이것 하나가 아니라 관측된 하나다. 12자리 숫자 형식도 쓰는데 그쪽은 국내 번호대와 겹쳐 감지할 수 없다 | - | https://ese.ecmsglobal.com/#/ecms/tracking?order_number={tracking_number} |
「응답의 carrier.code」가 택배사 이름과 다를 수 있습니다. 이 칸에 코드가 적힌 택배사는 번호가 다른 택배사 조회기로 라우팅된다는 뜻이라, 그대로 표시하면 이용자 화면에 엉뚱한 회사 이름이 뜹니다. 배송 상태 자체는 정상입니다. 이 칸이 -인 택배사는 상태를 아예 못 읽어 carrier_not_detected로 끝나고, 그건 번호가 틀린 것이 아닙니다.
건/일은 우리 한도가 아니라 택배사가 그 키 하나에 강제하는 한도입니다. 우리 잔량 헤더(X-Quota-*)에는 안 잡히므로, 우리 쪽 잔량이 남아 있어도 그 택배사만 먼저 막힙니다. 지금까지 이 제약은 한도를 다 쓴 뒤에야 드러났습니다. 이 목록으로 미리 확인하세요.
GET
/v1/results
조회한 결과는 이미 저장되고 있습니다. 키(KEY)가 속한 프로젝트에 쌓입니다. 이 엔드포인트가 그것을 꺼내 갑니다. 택배사를 다시 부르지 않으므로 호출 한도를 깎지 않습니다.
어느 프로젝트를 읽을지는 키가 정합니다. 주소나 파라미터로 다른 프로젝트를 지정할 수는 없습니다.
curl -H "Authorization: Bearer $TAEKBAE_API_KEY" \
"https://taekbae.net/v1/results?count=1"
{
"results": [
{
"id": "r_01J...",
"carrier": "cj",
"carrier_name": "CJ대한통운",
"tracking_number": "1234567890",
"status": "배달완료",
"receiver": "홍*동",
"origin": "api",
"api_key_name": "주문 시스템",
"first_seen_at": "2026-08-20T14:03:11+09:00",
"last_tracked_at": "2026-08-23T09:41:52+09:00"
}
],
"next_cursor": "<다음 장 커서>",
"total": 1284,
"store_enabled": true
}
next_cursor가 오면 그 값을 그대로 cursor에 넣어 다음 장을 받습니다. 안 오면 마지막 장입니다. 커서는 뜯어보지 마세요. 안이 어떻게 생겼는지는 예고 없이 바뀝니다. 총 건수가 필요하면 첫 요청에만 count=1을 붙이세요. 장마다 세면 목록보다 세는 값이 더 비쌉니다.
CURSOR=""
while :; do
RES=$(curl -sH "Authorization: Bearer $TAEKBAE_API_KEY" \
"https://taekbae.net/v1/results?size=500&cursor=$CURSOR")
echo "$RES" | jq -c '.results[]'
CURSOR=$(echo "$RES" | jq -r '.next_cursor // empty')
[ -z "$CURSOR" ] && break
done
| 파라미터 | 하는 일 |
|---|---|
q | 전체 검색. 공백으로 나눈 모든 낱말이 들어 있는 건만 옵니다. 택배사·운송장·받는분·보내는분·상품·상태·위치·처리일시·client_id(주문 참조)를 봅니다. 주문번호로 화물을 찾는 길입니다. |
carrier | 택배사 코드(cj · hanjin…). 이름이 아니라 코드입니다. 없는 코드를 주면 0건이 아니라 400이 옵니다. |
status | 배송상태 낱말. 쉼표로 여럿 주면 하나라도 맞는 건이 옵니다. |
receiver · sender | 받는 분·보내는 분 부분 일치. |
batch_id | 파일이나 표로 한 번에 올린 묶음. |
date_from · date_to | 마지막으로 조회한 날 기준 범위(YYYY-MM-DD, 한국 시간). 양쪽 날짜를 포함합니다. |
size | 한 장의 건수. 1~500, 기본 50. |
receiver · sender는 택배사마다 뜻이 다릅니다. CJ·우체국·한진·로젠·투데이는 가린 받는 분 이름이지만 롯데는 배달·집화 영업소 이름(신길(대))이 옵니다. 투데이의 sender는 보내는 쪽이 별표로만 오는 경우가 많아 그때는 판매처 이름(알리익스프레스 · 타오바오)이 대신 옵니다. 택배사 원본이 그 칸에 넣는 값이 서로 달라서입니다. 사람 이름을 기대하고 파싱하면 롯데 건에서 어긋납니다.
carrier가 null인 건이 섞일 수 있습니다. 지금 택배사 표에 없는 이름으로 저장된 오래된 건입니다. 그때도 carrier_name은 항상 옵니다. 없는 코드를 지어내지 않습니다.
store_enabled가 false면 앞으로 안 쌓는다는 뜻이고, 이미 쌓인 결과는 그대로 나옵니다. 저장을 껐다고 지난 자료가 사라지지는 않습니다.
GET
/v1/delays
지연 규칙에 걸린 화물을 돌려줍니다. 비즈니스 화면의 「지연 관리」와 아침 지연 요약이 보는 것과 같은 집합입니다. 아침마다 끌어가 CS 도구에 넣는 용도입니다. 택배사를 다시 부르지 않으므로 호출 한도를 깎지 않습니다.
규칙은 영업일 기준(택배사 달력)으로 마지막 스캔 뒤 경과를 셉니다. 규칙을 하나도 안 만들었으면 72시간 기본 규칙이 적용됩니다. 비즈니스 워크스페이스(체험·비즈니스·엔터프라이즈)가 아닌 프로젝트의 키(KEY)로 부르면 404 feature_unavailable 입니다.
curl -H "Authorization: Bearer $TAEKBAE_API_KEY" \
"https://taekbae.net/v1/delays"
{
"delays": [
{
"result_id": "r_01J...",
"rule_id": "d_01J...",
"rule_name": "기본(72시간)",
"carrier": "cj",
"carrier_name": "CJ대한통운",
"tracking_number": "1234567890",
"client_id": "order-1001",
"status": "간선상차",
"current_location": "옥천Hub",
"last_event_at": "2026-09-04T08:24:00+09:00",
"elapsed_value": 3,
"elapsed_unit": "business_days",
"elapsed_label": "3영업일",
"elapsed_business_days": 3,
"risk": {
"kind": "eta_upper",
"label": "09-07 07:00 조회 기준 · 도착 예상 상한까지 약 8시간, 이미 40시간 경과",
"observed_at": "2026-09-07 07:00:00",
"p90_hours": 48
}
}
],
"next_cursor": "50",
"total": 17,
"rules": [
{ "id": "d_01J...", "name": "기본(72시간)", "carrier": null, "stage": null, "business_hours": 72, "enabled": true }
]
}
| 파라미터 | 하는 일 |
|---|---|
rule | 규칙 하나만. 응답 rules[].id. |
carrier | 택배사 코드. 없는 코드는 400. |
cursor · size | /v1/results 와 같은 방식. size 는 1~200(기본 50). |
GET /v1/delays/no-scan 은 첫 조회로부터 24시간 뒤에 다시 조회했는데도 스캔이 하나도 없는 화물입니다. 송장은 등록됐는데 집화되지 않았거나 번호가 다른 것. 8월 운영 표본에서 이 상태의 10건 중 7~9건이 결국 늦었습니다(CJ대한통운·롯데택배·한진택배). 프로젝트가 지연 위험 배지를 켜야 오고, 안 켰으면 빈 목록입니다.
{
"no_scan": [
{
"result_id": "r_01J...",
"carrier": "cj",
"carrier_name": "CJ대한통운",
"tracking_number": "1234567890",
"client_id": "order-1001",
"first_seen_at": "2026-09-06T10:11:00+09:00",
"last_tracked_at": "2026-09-07T16:01:00+09:00",
"hours_since_first": 29,
"label": "09-07 16:01 조회 기준, 운송장 등록(첫 조회 09-06 10:11) 후 29시간째 택배사 스캔 기록이 없습니다."
}
],
"hours": 24,
"carriers": ["CJ대한통운", "롯데택배", "한진택배"]
}
처리한 화물은 POST /v1/delays/{result_id}/ack 로 「확인함」을 표시하면 목록에서 빠집니다. 화면·지연 요약·CSV 와 같은 저장소라 어디서 표시해도 같습니다. 새 택배사 스캔이 오거나 7일이 지나면 저절로 풀립니다. 지우려면 같은 주소에 DELETE.
curl -X POST -H "Authorization: Bearer $TAEKBAE_API_KEY" \
"https://taekbae.net/v1/delays/r_01J.../ack"
{ "acked": true }
risk 는 예측이 아니라 관측 진술입니다. 프로젝트가 비즈니스 화면에서 「지연 위험 배지」를 켰을 때만 오고, 「마지막 조회 시점에 도착 예상 상한의 80%를 지났다」는 사실만 담습니다. 8월 운영 표본에서 맞는 비율은 절반쯤입니다(CJ대한통운만 켭니다). 다시 조회해 볼 화물을 고르는 힌트로 쓰세요.
운송장을 등록해 두면 상태가 바뀔 때마다 콜백을 보냅니다. 붙이는 방법과 서명 확인은 상태가 바뀌면 알림 받기에 있습니다. 여기는 계약만 정리한 절입니다.
붙이고 나서가 더 중요해서, 운영에 필요한 것들을 처음부터 넣어 뒀습니다.
보낸 기록 전체 조회와 건별 재전송이 되므로 놓친 콜백은 코드를 고치지 않고
복구합니다. 서명 키는 무중단으로 교체되는데, 교체 유예 동안 옛 키와 새 키
서명이 같이 실려서 받는 쪽 배포와 순서를 맞출 필요가 없습니다. 여기에 배송 데이터를 직접
다루는 서비스만 낼 수 있는 신호로 정체(stalled)·지연(delayed)
이벤트가 있습니다. 콜백이 실패하면 어떤 간격으로 몇 번 재시도하고 언제 멈추는지도
전부 이 문서와 GET /v1/webhooks/deliveries로 확인됩니다. 짐작할 것이
없게 하는 것이 목표입니다.
| 엔드포인트 | 하는 일 | 쿼터 |
|---|---|---|
POST /v1/webhooks | 구독 등록 | 운송장 수만큼 차감 |
GET /v1/webhooks | 구독 목록 (?cursor= · ?limit=으로 나눠 받기) | 차감 없음 |
GET /v1/webhooks/{subscription_id} | 구독 상세 (운송장별 상태 포함) | 차감 없음 |
DELETE /v1/webhooks/{subscription_id} | 해지 (즉시 멈춤, 204) | 차감 없음 |
POST /v1/webhooks/{subscription_id}/items | 운송장 추가 (중복은 건너뜀) | 실제로 추가된 수만큼 |
DELETE /v1/webhooks/{subscription_id}/items/{item_id} | 운송장 하나만 빼기 (204) | 차감 없음 |
POST /v1/webhooks/{subscription_id}/test | 테스트 콜백 보내고 결과 받기 | 차감 없음 |
POST /v1/webhooks/{subscription_id}/resume | 멈춘 구독 되살리기 | 차감 없음 |
POST /v1/webhooks/{subscription_id}/renew | 만료 미루기 | 차감 없음 |
POST /v1/webhooks/{subscription_id}/rotate-secret | 서명 키 교체 (무중단) | 차감 없음 |
GET /v1/webhooks/deliveries | 보낸 기록 목록 | 차감 없음 |
GET /v1/webhooks/deliveries/{delivery_id} | 보낸 기록 상세 (본문 포함) | 차감 없음 |
POST /v1/webhooks/deliveries/{delivery_id}/replay | 그 콜백 다시 보내기 | 차감 없음 |
GET /v1/webhooks/config | 발신 IP·서명 규약·한도 안내 | 차감 없음 |
| 필드 | 필수 | 설명 |
|---|---|---|
verify | X | 등록 전에 그 주소로 webhook.test 콜백을 한 번 보내고 2xx가 아니면 등록을 거절합니다(400 callback_verification_failed). 기본은 false입니다. 수신 서버를 나중에 띄우는 배포 순서를 깨지 않으려는 기본값입니다. 거절된 요청은 호출 한도를 깎지 않습니다. |
url | O | 콜백을 받을 https 또는 http 주소. 내부망·루프백 주소, 인증 정보가 담긴 URL은 거절합니다. http는 평문이라 본문과 커스텀 헤더가 중간에 그대로 보입니다. 사내망 안이 아니라면 https를 쓰세요. |
items[] | O | 운송장 1~100건. |
items[].tracking_number | O | 하이픈은 자동 제거, 영문은 대문자로 정규화합니다. |
items[].carrier | X | 생략하면 자동 감지합니다. 확신이 없으면 후보를 담아 422로 답합니다. 구독은 14일을 사는 자원이라 조회와 달리 추측으로 등록하지 않습니다. |
items[].client_id | X | 주문번호 같은 여러분 쪽 식별자. 콜백과 조회 응답에 그대로 돌아옵니다. |
metadata | X | 콜백에 그대로 실려 돌아오는 임의 객체. |
expires_at | X | 생략하면 14일 뒤입니다. 14일을 넘겨 보내면 14일로 잘라서 받고, 응답의 expires_at이 실제 값입니다. |
event_types[] | X | 받을 이벤트만 고릅니다. 생략하면 전부입니다. 빈 배열은 거절합니다. 전부인지 아무것도 아닌지 알 수 없어서입니다. |
headers | X | 콜백에 함께 보낼 헤더(예: {"Authorization": "Bearer …"}). 저희가 쓰는 헤더와 연결 관련 헤더는 지정할 수 없고, 값은 조회 응답에 나오지 않습니다. |
배송 상태는 접수부터 완료까지 여러 번 바뀝니다. 그런데 고객에게 실제로 알리는 건 보통 두세 번이지요. 발송했을 때, 배송이 시작됐을 때, 도착했을 때. 알림톡처럼 건당 요금이 붙는 곳에 연결한다면 필요한 것만 골라 두세요.
{
"url": "https://example.com/webhooks/taekbae",
"items": [{ "tracking_number": "CA123456789KR", "client_id": "order-1001" }],
"event_types": ["tracking.exception", "tracking.completed"]
}
| 이벤트 | 언제 오나 |
|---|---|
tracking.updated | 배송 상태나 이력이 달라질 때마다. |
tracking.pickup_pending | 접수만 되고 집화가 없는 채로 영업일이 지났을 때 한 번. 가송장 신호입니다. |
tracking.exception | 분실·파손·주소불명 등 사람이 손을 써야 하는 사고. 상담 쪽으로 보내세요. |
tracking.stalled | 배송 이력이 5일째 그대로(이력이 아예 없는 번호는 3일)일 때 한 번. 본문의 stalled.reason이 no_events(이력 없음, 운송장번호 확인)인지 no_progress(멈춤, 택배사 문의)인지 알려 줍니다. |
tracking.delayed | 도착 예상(ETA)을 넘겼는데 아직 배송 중일 때 한 번. 예상이 없는 화물에는 오지 않습니다. 본문에 eta_exceeded: true와 delay_source: "eta"가 같이 실립니다. 지연 규칙을 켜면 같은 이름으로 delay_source: "rule"도 옵니다(아래). |
tracking.completed | 배송완료·반송·취소. 마지막 콜백이고 여기서 구독이 끝납니다. |
| 쓰임새 | 추천 조합 | 받으면 하는 일 |
|---|---|---|
| 알림톡·문자 자동 발송 | ["tracking.completed", "tracking.stalled", "tracking.delayed"] |
완료는 "상품이 도착했어요", 정체·지연은 "배송이 늦어지고 있어요, 확인 중입니다". 건당 요금이 붙는 채널이라 updated는 빼는 게 요금을 아낍니다. |
| 쇼핑몰·ERP 상태 갱신 | 전부 (필터 없음) | 주문 화면의 배송 상태를 콜백 본문 그대로 갱신. 화면 표시는 무료이니 다 받아 두는 쪽이 단순합니다. |
| CS·운영 대응 | ["tracking.exception", "tracking.pickup_pending", "tracking.stalled"] |
사고는 상담 채널로, 가송장(pickup_pending)과 이력 없음(stalled + no_events)은 판매자 확인 요청으로. 고객이 묻기 전에 먼저 연락하는 팀이 이 조합을 씁니다. |
고르지 않은 이벤트는 보내지 않을 뿐 추적은 그대로 이어집니다.
나중에 tracking.completed가 필요해지면 그때까지의 이력은
콜백 본문의 data.events에 전부 들어 있습니다.
테스트 발송은 이 설정과 무관하게 나갑니다. 직접 요청하신 것이니까요.
delay_source로 가르세요
tracking.delayed를 쏘는 판정이 둘입니다. 하나는 우리 모델이 정하는
도착 예상(ETA) 초과이고, 다른 하나는 판매자가 정한 지연 규칙(예: "로젠 4영업일")입니다.
기준도 정하는 사람도 다르므로 본문의 delay_source로 갈라 받으세요.
delay_source | 무엇이 정하나 | 본문 |
|---|---|---|
"eta" |
우리 모델. 단계별 도착 예상 상한(p90)에 유예를 더한 값을 넘겼을 때. 예상이 없는 화물에는 오지 않습니다. | data가 단건 조회 응답과 같습니다. eta_exceeded: true가 같이 옵니다. |
"rule" |
비즈니스 워크스페이스의 지연 규칙. 택배사 달력으로 센 영업일 기준이라 주말·공휴일·택배사 휴무는 빠집니다. | 지연 요약과 같은 목록·같은 마스킹입니다. 운송장 원문과 수취인은 실리지 않고, 대신 result_id와 rule이 옵니다. |
{
"id": "whev_7c1b2a3d4e5f60718293a4b5c6d7e8f9",
"event": "tracking.delayed",
"subscription_id": "whs_1a2b3c4d5e6f",
"client_id": "order-1001",
"sent_at": "2026-09-08T08:30:00+09:00",
"data": {
"carrier": "logen",
"carrier_name": "로젠택배",
"tracking_number": "123*******12",
"result_id": "0b7f1d2e-3a4b-4c5d-8e9f-0a1b2c3d4e5f",
"status": "배송중",
"current_location": "동서울",
"last_event_at": "2026-09-03T18:20:00+09:00",
"as_of": "2026-09-08T08:30:00+09:00",
"delay_source": "rule",
"rule": { "id": "…", "name": "로젠 4영업일", "business_days_elapsed": 4 }
}
}
규칙 지연은 켜야 옵니다(기본 꺼짐).
/biz/delays의 규칙에서 알림을 "지연 요약 + 웹훅"이나 "웹훅"으로 바꾸면
그때부터 그 규칙에 걸린 화물이 콜백으로도 갑니다. 켜지 않은 구독의 발송량은 달라지지 않습니다.
규칙을 바꾸면 물량이 바뀌므로 기본값을 꺼진 쪽으로 둔 것입니다.
발송 시각은 지연 요약과 같고 목록도 같습니다. 텔레그램으로 받는 목록과 콜백이 다른 화물을 말하지 않습니다.
운송장 원문이 필요하면 result_id로 GET /v1/delays를 부르세요(키를 가진 프로젝트는 원문을 봅니다).
이 콜백은 재시도하지 않습니다. 실패는 발송 이력에 남고, 같은 화물이 다음 날에도 규칙에 걸려 있으면 다시 옵니다.
실제 운송장이 없어도 콜백을 한 번 받아 볼 수 있습니다. 서명과 헤더가 실제 콜백과 완전히 같으므로 검증 코드를 이걸로 확인하세요. 응답에 받는 쪽이 뭐라고 답했는지가 그대로 들어 있어서, 로그를 뒤질 필요가 없습니다.
curl -X POST https://taekbae.net/v1/webhooks/$SUB_ID/test \
-H "Authorization: Bearer $TAEKBAE_API_KEY"
# {
# "delivery_id": "whd_3a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d",
# "event_id": "whev_7c1b2a3d4e5f60718293a4b5c6d7e8f9",
# "delivered": false,
# "outcome": "rejected",
# "response_status": 401,
# "duration_ms": 118,
# "error": "HTTP 401"
# }
받는 쪽이 실패로 답해도 이 요청 자체는 200입니다.
답하는 질문이 "보낼 수 있었나"가 아니라 "뭐라고 답했나"이기 때문입니다.
위 예시는 서명 검증이 아직 안 맞아서 401을 받은 모습입니다.
언제 몇 번 보냈고 받는 쪽이 뭐라고 답했는지 그대로 남겨 둡니다. 문의를 넣고 기다릴 필요 없이 여기서 바로 보세요.
curl -H "Authorization: Bearer $TAEKBAE_API_KEY" \
"https://taekbae.net/v1/webhooks/deliveries?status=failed&limit=20"
# 하나를 골라 자세히 보면 보낸 본문까지 나온다
curl -H "Authorization: Bearer $TAEKBAE_API_KEY" \
"https://taekbae.net/v1/webhooks/deliveries/$DELIVERY_ID"
# 받는 쪽을 고쳤다면 그대로 다시 보냅니다
curl -X POST -H "Authorization: Bearer $TAEKBAE_API_KEY" \
"https://taekbae.net/v1/webhooks/deliveries/$DELIVERY_ID/replay"
| 필드 | 설명 |
|---|---|
status | pending은 아직 다시 보낼 예정, delivered는 성공, failed는 더 시도하지 않는 것입니다. ?status=로 걸러 볼 수 있습니다. 골라 받기로 제외한 이벤트는 여기 남지 않습니다. 보내지 않기로 한 것이라 다시 보낼 대상도 아닙니다. |
attempts | 지금까지 보낸 횟수. |
response_status · duration_ms · error | 마지막 시도에 받은 상태 코드와 걸린 시간, 실패 사유. |
payload | 보낸 본문 그대로. 상세 조회에만 실립니다(목록에 넣으면 너무 무겁습니다). |
event_id | 콜백 본문의 id와 같은 값. 여러분 로그와 맞춰 볼 때 씁니다. |
다시 보내도 이벤트 ID는 그대로입니다. 같은 이벤트를 다시 보내는 것이지 새 이벤트가 아니니까요. 이미 처리하신 것이면 그 값으로 걸러 내시면 됩니다. 기록은 30일간 보관합니다. 같은 기록을 연달아 다시 보내는 것은 몇 초 간격으로 제한합니다. 막 살아난 서버를 다시 쓰러뜨리지 않으려는 장치입니다.
구독을 새로 만들 필요 없이 있는 구독에 운송장만 더하면 됩니다. 콜백 주소도 서명 키도 그대로라 받는 쪽 코드를 건드리지 않습니다.
curl -X POST https://taekbae.net/v1/webhooks/$SUB_ID/items \
-H "Authorization: Bearer $TAEKBAE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "items": [
{ "tracking_number": "12345678901", "client_id": "order-1002" },
{ "tracking_number": "CA123456789KR", "client_id": "order-1003" }
] }'
# → { "subscription": {...}, "added": 2, "skipped": 0 }
이미 들어 있는 운송장은 건너뛰고 차감도 하지 않습니다.
그래서 "오늘 나간 주문" 목록을 통째로 다시 보내도 괜찮습니다.
무엇이 새것인지 여러분이 계산하지 않아도 됩니다.
다 쓴 운송장은 DELETE …/items/{item_id}로 빼세요
(item_id는 구독 상세의 items[].id입니다).
해외 배송처럼 오래 걸리는 화물은 만료를 미루세요. 다시 등록하는 것과 달리
구독 ID도 서명 키도 그대로입니다.
응답의 expires_at이 실제로 적용된 값이니 그걸 보고 다음 시점을 정하시면 됩니다.
curl -X POST -H "Authorization: Bearer $TAEKBAE_API_KEY" \
"https://taekbae.net/v1/webhooks/$SUB_ID/renew"
# 서명 키 교체(하루 동안은 옛 키 서명도 같이 간다)
curl -X POST https://taekbae.net/v1/webhooks/$SUB_ID/rotate-secret \
-H "Authorization: Bearer $TAEKBAE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "grace_seconds": 86400 }'
# → { ..., "secret": "whsec_…" } ← 이 응답에만 나옵니다
교체 유예 동안에는 X-Webhook-Signature에 서명이
공백으로 구분돼 두 개 옵니다. 받는 쪽은 하나만 맞으면 통과시키면 되고,
그래서 배포 타이밍을 맞추지 않아도 됩니다. 새 키를 넣고 배포한 뒤
유예가 끝나기를 기다리면 그만입니다.
구독 하나가 갱신을 반복해 살 수 있는 기간에는 상한이 있고, 넘기면
subscription_lifetime_exceeded로 답합니다. 그때는 새로 등록하세요.
받는 서버를 직접 만들 필요가 없습니다. Zapier·Make 같은 자동화 도구는 「웹훅으로 받기」 모듈을 만들면 https 주소를 하나 내줍니다. 그 주소를 콜백 주소로 등록하면, 그다음은 그쪽 화면에서 끌어다 놓는 것으로 알림톡·슬랙·구글시트·메일까지 이어집니다.
url 로 넣어 구독을 등록합니다.POST /v1/webhooks/{subscription_id}/test 로 한 번 쏴 보고,
그쪽 화면에 값이 들어오는지 확인합니다. 실제 운송장을 기다릴 필요가 없습니다.client_id(주문번호)와
data.status.code(배송 상태)를 꺼내 쓰면 됩니다.
이때 event_types 를 꼭 골라 두세요.
이런 도구는 대개 받은 건수만큼 요금이 붙습니다.
배송 상태는 접수부터 완료까지 여러 번 바뀌는데, 필요한 게 두세 번이라면
나머지는 받는 순간부터 그냥 비용입니다. 골라 두면 그만큼 줄어듭니다.
이 방식에서는 서명 확인을 못 합니다. 자동화 도구는
X-Webhook-Signature 를 검사하지 않고 들어온 것을 그대로 받습니다.
주소를 아는 사람은 누구나 가짜 데이터를 넣을 수 있다는 뜻이니,
주소를 공개된 곳에 적지 마세요. 돈이 오가거나 되돌리기 어려운
처리를 여기에 물릴 거라면, 서명을 확인하는 서버를 직접 두는 편이 맞습니다.
콜백은 정해진 주소에서만 나갑니다. 사내 방화벽에서 인바운드를
막아 두셨다면 아래로 확인해 그 주소만 열어 두시면 됩니다.
받는 쪽 게이트웨이가 Authorization 같은 헤더를 요구한다면
등록할 때 headers에 넣어 주세요.
curl -H "Authorization: Bearer $TAEKBAE_API_KEY" \
https://taekbae.net/v1/webhooks/config
# {
# "egress_ips": ["203.0.113.10"],
# "event_types": ["tracking.updated", "tracking.pickup_pending",
# "tracking.exception", "tracking.completed"],
# "signature": { "algorithm": "HMAC-SHA256", "signed_payload": "{timestamp}.{body}",
# "tolerance_seconds": 300, "multiple_signatures": true, ... },
# "limits": { "max_items_per_subscription": 100, ... }
# }
egress_ips가 빈 배열이면 아직 공개하지 않은 것이니
방화벽을 열어 두세요. 틀린 주소를 넣으면 콜백이 통째로 막힙니다.
모를 때는 알려 드리지 않는 편이 안전합니다.
GET /v1/webhooks는 한 번에 최신 200건까지 줍니다. 뒤에 더 있으면
응답의 has_more가 true가 되고 next_cursor에
이어 읽을 위치가 실립니다. 그 값을 ?cursor=에 그대로 넣어 다음 장을
받으세요. next_cursor가 null이면 마지막 장입니다.
커서는 저희가 만든 값이니 직접 만들어 쓰지 마세요.
curl -H "Authorization: Bearer $TAEKBAE_API_KEY" \
"https://taekbae.net/v1/webhooks?limit=100"
# → { "subscriptions": [...], "next_cursor": "…", "has_more": true }
# 위 응답의 next_cursor 값을 그대로 넣습니다. has_more 가 false 가 될 때까지 반복하세요.
curl -H "Authorization: Bearer $TAEKBAE_API_KEY" \
"https://taekbae.net/v1/webhooks?limit=100&cursor=$NEXT_CURSOR"
{
"id": "whs_9f2c1e5b7a4d4a0f8b1c2d3e4f5a6b7c",
"url": "https://example.com/webhooks/taekbae",
"status": "active",
"created_at": "2026-08-14T09:00:00+09:00",
"expires_at": "2026-08-28T09:00:00+09:00",
"item_count": 1,
"metadata": {
"shop": "my-store"
},
"event_types": null,
"items": [
{
"id": "whi_5d4c3b2a1908f7e6d5c4b3a291807f6e",
"carrier": {
"code": "cj",
"name": "CJ대한통운"
},
"tracking_number": "12345678901",
"client_id": "order-1001",
"status": "out_for_delivery",
"is_final": false,
"last_event_at": "2026-08-14T09:12:00+09:00",
"last_checked_at": "2026-08-14T09:40:02+09:00",
"next_check_at": "2026-08-14T10:40:02+09:00",
"delivery_pending": false
}
],
"delivery_summary": {
"window_days": 30,
"total": 42,
"delivered": 42,
"success_rate": 1.0
}
}
| 필드 | 설명 |
|---|---|
secret | 콜백 서명 키. 등록 응답과 키 교체 응답에만 실립니다. 잃어버렸으면 rotate-secret으로 새로 받으세요. |
status | active · cancelled · expired · suspended. suspended는 콜백이 연속 실패해 저희가 멈춘 상태이고, resume으로 되살릴 수 있습니다. |
event_types | 이 구독이 받는 이벤트. null이면 전부입니다. |
header_names | 지정한 커스텀 헤더의 이름만. 값은 다시 나가지 않습니다. |
secret_rotated_at | 마지막으로 키를 바꾼 시각. 한 번도 안 바꿨으면 없습니다. |
items[].id | 이 운송장만 뺄 때 씁니다(DELETE …/items/{item_id}). |
items[].status | 마지막으로 관측한 배송 상태. 아직 한 번도 확인하지 않았으면 키가 없습니다. |
items[].next_check_at | 다음 확인 예정 시각. 배송 단계에 따라 다릅니다(확인 주기). |
items[].stalled_at | 물건이 한동안 움직이지 않아 tracking.stalled 알림을 보낸 시각. 정상 흐름이면 없습니다. |
items[].delayed_at | 평소보다 배송이 늦어 tracking.delayed 알림을 보낸 시각. 정상 흐름이면 없습니다. |
delivery_summary | 최근 30일 콜백 성적(보낸 수·성공 수·성공률). 상세 조회에만 실립니다. 0건이면 success_rate가 없습니다. 표본 없는 100%를 지어내지 않습니다. |
items[].delivery_pending | 콜백이 아직 안 나갔습니다. GET /v1/webhooks/deliveries 의 attempts가 0이면 발송 대기열에 있는 것이고(곧 나갑니다), 1 이상이면 재시도 대기입니다. 이때가 받는 쪽을 확인하라는 신호입니다. |
| 항목 | 상한 | 넘으면 |
|---|---|---|
| 한 구독의 운송장 수 | 100건 | 넘으면 too_many_items. 구독을 나눠 등록하세요. 구독 수 자체에는 제한이 없습니다. |
| 동시에 추적하는 운송장 | 1,000건 | 넘으면 subscription_limit_exceeded. 배송이 끝난 건은 자동으로 빠집니다. |
| 한 구독의 운송장 수 (추가 포함) | 100건 | 있는 구독에 더할 때도 같은 상한입니다. |
| 구독 수명 | 최대 14일 | 더 필요하면 renew로 미루세요. |
| 갱신을 반복해도 총 수명 | 최대 90일 | 넘으면 subscription_lifetime_exceeded. 새로 등록하세요. |
| 보낸 기록 보관 | 30일 | 그 안에는 다시 보낼 수 있습니다. |
| 커스텀 헤더 | 10개 | 이름 64자·값 1,024자까지. |
| 콜백 응답 제한 시간 | 10초 | 넘으면 실패로 보고 재시도합니다. |
metadata 크기 | 4 KiB | 넘으면 invalid_parameter. |
client_id 길이 | 128자 | 주문번호를 담기에 충분합니다. |
| 콜백 URL 길이 | 2,048자 |
GET
/v1/usage
남은 호출 수와 리셋 시각을 돌려줍니다. 헤더는 요청을 보내야만 볼 수 있어서 대시보드나 알림을 만들기 어렵기 때문에 별도로 둡니다. 이 호출은 쿼터를 차감하지 않습니다.
{
"enforced": true,
"remaining": 431,
"reset_at": "2026-07-27T00:00:00+09:00",
"scope": "daily"
}
모든 조회 응답에 ETag와 Cache-Control이 붙습니다. If-None-Match로 조건부 요청을 보내면 배송 내용이 그대로일 때 304가 돌아옵니다.
# 1) 먼저 조회하고 응답 헤더에서 ETag를 받는다 (-i로 상태줄·헤더까지 출력)
curl -i "https://taekbae.net/v1/tracking/$TRACKING_NUMBER" \
-H "Authorization: Bearer $TAEKBAE_API_KEY"
HTTP/1.1 200 OK
etag: "c657463a16ec1a5e"
cache-control: max-age=300
# 2) 받은 ETag를 그대로 실어 다시 부른다. 내용이 그대로면 304
curl -i "https://taekbae.net/v1/tracking/$TRACKING_NUMBER" \
-H "Authorization: Bearer $TAEKBAE_API_KEY" \
-H 'If-None-Match: "c657463a16ec1a5e"'
HTTP/1.1 304 Not Modified
ETag 값은 배송 내용마다 다릅니다. 위 값을 그대로 쓰지 말고 1)에서 받은 값을 실으세요. -i가 없으면 304는 본문이 없어 화면에 아무것도 찍히지 않아, 조건부 요청이 동작한 것인지 요청이 실패한 것인지 구분할 수 없습니다.
ETag는 배송 데이터로만 만듭니다. 우리가 택배사를 다시 불러 본문의 as_of가 갱신돼도, 이력이 그대로면 같은 값입니다. 그래서 아래 권장 주기대로 폴링해도 조건부 요청이 제대로 동작합니다. 이번 응답이 언제 기준인지는 본문의 as_of, 그리고 헤더의 Date와 Age로 알 수 있습니다. 304라 본문을 못 봤을 때도 마찬가지입니다.
택배사를 실제로 부를 때만 호출 수를 셉니다. 캐시에서 답이 나가면 200이든 304든 차감하지 않습니다. 캐시 수명이 지나 택배사를 다시 부른 요청은 결과가 304여도 1건을 셉니다. 조회 자체는 실제로 일어났기 때문입니다. 어느 쪽이든 조건부 요청을 쓰면 본문 전송을 아낍니다.
Cache-Control의 max-age가 권장 폴링 주기입니다. 배송이 끝난 건은 24시간, 배송출발 상태는 5분입니다. status.is_final이 true면 폴링을 멈추는 게 가장 좋습니다.
우리 캐시에서 답한 응답에는 Age가 붙고 max-age에는 남은 수명만 옵니다. 예를 들어 처음 가져온 지 163초 지난 응답은 age: 163, cache-control: max-age=437로 옵니다. 두 값을 더하면 항상 원래 수명이라, 여러분이 계산하는 만료 시각과 우리 캐시가 실제로 버려지는 시각이 같습니다.
현재 API는 전면 무료입니다. 개발용이든 상용 서비스든 별도 비용 없이 바로 쓸 수 있습니다.
키에 걸린 한도는 어뷰징을 막기 위한 안전장치이고, 일반적인 개발과 서비스 운영에서는 사실상 제한 없이 쓰실 수 있습니다. 값 자체는 계정별로 조정되므로 문서에 못 박지 않고, 내 키의 실제 한도와 잔량은 아래 응답 헤더가 정본입니다.
| 구분 | 비용 | 한도 |
|---|---|---|
| 기본 무료 (Free) | 0원 | 회원가입하고 키를 발급하면 자동 적용. 어뷰징 차단선만 걸려 있습니다. |
| 비즈니스 / 대량 연동 | 무상 협의 | 예상 트래픽을 알려주시면 검토 후 증설합니다. |
키에 걸린 한도와 남은 양은 응답 헤더로 알려줍니다.
| 헤더 | 의미 |
|---|---|
RateLimit-Remaining | 현재 창에서 남은 호출 수 |
RateLimit-Reset | 창이 리셋될 때까지 남은 초 |
RateLimit-Scope | daily 또는 monthly. 두 창 중 더 임박한 쪽을 알려줍니다. |
성공 응답뿐 아니라 쿼터를 조회한 오류 응답에도 같은 헤더가 옵니다. 차감이 일어나는 404 tracking_number_not_found도 포함이라, 접수 전 번호를 폴링하는 동안에도 잔량을 헤더만으로 추적할 수 있습니다. 인증 실패(401)처럼 쿼터를 보기 전에 끊긴 응답에는 없습니다.
한도를 넘기면 429와 Retry-After가 옵니다. 이때도 잔량 헤더가 함께 옵니다. 어느 창에 걸렸는지는 응답의 scope로 확인하세요.
호출이 실패해도 우리 잘못이면 차감하지 않습니다. 택배사 장애(503)나 서버 오류(5xx)는 자동으로 되돌립니다. 아래 표의 차감 열을 참고하세요.
API 도입 검토 시 자주 문의하시는 질문들을 정리했습니다.
AI 팀이 개발·모니터링·운영을 상시 맡고 있는 서비스입니다(소개). 2025년 9월에 열었고, 그 뒤 가용성은 99.8% 수준입니다. 서버와 17개 택배사의 통신 상태는 자동 모니터링이 24시간 지켜보고, 끊기면 알림이 옵니다.
전용 서버를 연 단위로 계약해 운영합니다. 정책을 바꿔야 할 일이 생기면 유예 기간을 두고 미리 공지합니다. 장애나 문의는 AI 팀이 24시간 모니터링하며 실시간으로 대응합니다.
지금은 전부 무료입니다. 정식 플랜이나 정책이 바뀌면 이미 붙여 둔 쪽이 옮길 시간을 갖도록, 서비스 안 공지와 등록된 이메일로 미리 알리고 유예 기간을 둡니다.
지금 걸려 있는 한도는 어뷰징을 막기 위한 안전장치라, 일반적인 개발과 서비스 운영에서는 사실상 제한 없이 쓰실 수 있습니다. 값은 계정별로 조정되므로 문서에 적어 두지 않고, 내 키의 한도와 남은 양은 응답 헤더(RateLimit-Remaining)로 언제든 확인할 수 있습니다. 대량 주문 처리나 비즈니스 연동으로 그보다 많이 필요하시면 문의하기 또는 [email protected]으로 예상 트래픽을 알려주시면 검토 후 증설해 드립니다.
배송 데이터의 원본은 각 택배사이고, 택배넷은 요청 시점에 그 값을 받아 옵니다. 택배사 쪽이 점검 중이거나 통신이 밀리면 최신 상태가 반영되기까지 시간차가 생깁니다. 배송지 변경이나 분실처럼 실물 배송에 대한 조치는 택배사 고객센터에서만 됩니다.
네, 언제든 자유롭게 받습니다. 필요한 응답 필드, 연동 방식, 도입 검토 중 궁금한 점 등 형식을 갖추지 않은 문의도 상관없습니다. 문의하기 또는 [email protected]으로 보내 주세요.
오류는 항상 4xx/5xx로 옵니다. 200 본문에 실패를 담지 않습니다. 본문은 RFC 9457 application/problem+json 형식입니다.
{
"type": "https://taekbae.net/docs/api/errors/carrier_unavailable",
"title": "택배사 시스템에 접근할 수 없습니다",
"status": 503,
"code": "carrier_unavailable",
"carrier": "cj",
"detected": true,
"request_id": "req_75999e533cfc499a995c0bf769579d7e"
}
code로 분기하세요. title 문구는 바뀔 수 있지만 code는 유지됩니다. 문의할 때는 request_id를 함께 알려주시면 해당 요청을 바로 찾을 수 있습니다.
| 필드 | 설명 |
|---|---|
code | 안정된 기계 판독용 코드. 분기는 이 값으로 하세요. |
carrier | 이 오류가 난 택배사 코드. 택배사를 실제로 부른 오류에만 옵니다. |
detected | 위 carrier가 우리가 추론한 값인지(carrier를 생략한 호출인지). /v1/tracking의 오류에 실립니다. 200의 carrier.detected와 같은 값입니다. |
candidates · tried | carrier_not_detected에만. 자동 감지 참고. |
cached | carrier_not_detected에만. true면 이번 요청에서 택배사를 부르지 않고, 최근 전탐색이 어느 택배사에도 없다고 확인한 결과를 그대로 돌려준 것입니다(차감 없음). 접수 직후라면 잠시 뒤 다시 시도하세요. 필드가 아예 없으면 이번 요청에서 판정한 결과입니다. tried가 []인 모양은 두 경우가 같지만 다음에 할 일이 다릅니다. 캐시 히트는 "최근 조회에서 어느 택배사에도 없던 운송장번호입니다…"라는 title과 함께 잠시 뒤 재시도, 후보를 하나도 못 뽑은 경우는 "…남은 후보가 없으니 carrier 파라미터로 직접 지정해 주세요"와 함께 carrier 직접 지정입니다. |
scope | quota_exceeded에만. daily 또는 monthly. |
detected가 true인 5xx는 택배사 장애가 아닐 수 있습니다. 우리 추론이 빗나가 엉뚱한 택배사를 부른 것일 수 있고, 그렇다면 몇 번을 재시도해도 결과가 같습니다. 이때는 재시도 대신 carrier를 직접 지정해 한 번 더 확인해 보세요.
invalid_tracking_number
400
차감 없음
운송장번호 형식이 아닙니다. 영문·숫자 8~20자여야 하고, 숫자가 하나 이상 들어가야 합니다.
대응 입력값을 확인하세요. 하이픈은 우리가 제거하므로 그대로 보내도 됩니다.
invalid_parameter
400
차감 없음
쿼리 파라미터 값을 해석할 수 없거나 같은 파라미터를 두 번 보냈습니다. 응답의 parameter가 어느 파라미터인지 알려줍니다.
대응 events는 full·latest·none, eta는 true/false(1/0, yes/no, on/off도 됩니다)만 받습니다. 각 파라미터는 한 번만 보내세요.
missing_api_key
401
차감 없음
Authorization 헤더가 없거나 Bearer 형식이 아닙니다.
대응 Authorization: Bearer tb_...를 붙이세요. 쿼리스트링 키는 지원하지 않습니다.
invalid_api_key
401
차감 없음
그런 키가 없습니다. 오타이거나 다른 환경의 키일 수 있습니다.
대응 키 문자열을 다시 확인하세요. 앞뒤 공백·줄바꿈이 섞여 들어오는 경우가 가장 흔합니다.
revoked_api_key
401
차감 없음
한때 유효했지만 폐기된 키입니다. 키 자체는 맞습니다.
대응 콘솔에서 새 키를 발급해 바꿔 넣으세요. 폐기는 되돌릴 수 없습니다.
account_suspended
403
차감 없음
키는 유효하지만 계정 이용이 제한되어 있습니다.
대응 키를 다시 발급해도 해결되지 않습니다. 콘솔에 로그인하면 상태를 볼 수 있고, 문의로 알려 주시면 확인합니다.
carrier_not_supported
404
차감 없음
지원하지 않는 택배사 슬러그입니다. 응답의 supported에 전체 목록이 들어 있습니다.
대응 GET /v1/carriers로 사용 가능한 코드를 확인하세요.
tracking_number_not_found
404
차감
택배사에 해당 운송장이 없습니다. 접수 직후라 아직 등록되지 않았을 수 있습니다.
대응 접수 직후라면 잠시 뒤 재시도하세요. 택배사 장애와는 구분되는 응답입니다.
not_found
404
차감 없음
그런 엔드포인트가 없습니다. 운송장이 없다는 뜻이 아닙니다. 그건 tracking_number_not_found입니다.
대응 GET /v1/openapi.json의 경로 목록과 대조하세요. 오타난 경로가 대부분입니다.
method_not_allowed
405
차감 없음
경로는 있지만 그 메서드를 지원하지 않습니다. Allow 헤더에 허용 메서드가 옵니다.
대응 Allow 헤더의 메서드로 다시 호출하세요. v1은 전부 조회 API라 GET입니다.
carrier_not_detected
422
차감
운송장번호만으로 택배사를 특정하지 못했습니다. tried가 이미 조회한 택배사, candidates가 아직 시도하지 않은 후보입니다. 후보를 다 썼거나 애초에 못 뽑았으면 candidates는 빈 배열입니다. 택배사를 실제로 조회했으므로 1건 차감합니다. 후보를 하나도 못 뽑아 상류를 아예 부르지 않은 경우(tried가 빈 배열)만 차감하지 않습니다.
대응 candidates가 비어 있지 않으면 그중 하나를 carrier로 지정해 다시 호출하세요. 비어 있으면 GET /v1/carriers에서 택배사를 직접 고르세요. tried의 택배사는 방금 조회해 정보가 없었으므로 다시 시도해도 결과가 같습니다.
quota_exceeded
429
차감 없음
요청 한도를 초과했습니다. scope가 일·월 중 어느 창인지 알려줍니다.
대응 Retry-After만큼 기다렸다 재시도하세요.
carrier_error
502
차감 없음
택배사가 응답했지만 우리가 해석하지 못했습니다.
대응 재시도하고, 반복되면 request_id와 함께 문의하세요.
carrier_unavailable
503
차감 없음
택배사 시스템에 접근할 수 없습니다. 택배사 점검·장애·차단입니다.
대응 Retry-After 후 재시도하세요. 운송장이 없는 것과는 다릅니다.
internal_error
500
차감 없음
우리 쪽 오류입니다.
대응 request_id와 함께 문의해 주세요.
invalid_request_body
400
422
차감 없음
요청 본문을 해석할 수 없거나(JSON 형식·Content-Type), 필요한 항목이 없습니다. 형식 오류는 400, 항목 누락·타입 불일치는 422입니다.
대응 Content-Type: application/json과 본문 형식을 확인하세요. 어느 항목이 문제인지는 응답의 parameter를 보세요.
request_body_too_large
413
차감 없음
요청 본문이 서버가 받는 크기를 넘었습니다.
대응 구독을 나눠서 등록하세요. 본문 형식 문제가 아니므로 JSON을 다시 들여다볼 필요는 없습니다.
invalid_webhook_url
400
차감 없음
콜백 URL을 받을 수 없습니다. https가 아니거나, 내부망·루프백 주소이거나, URL에 인증 정보가 들어 있습니다.
대응 공개된 https 주소를 쓰세요. 인증이 필요하면 URL이 아니라 서명(X-Webhook-Signature)으로 확인하세요.
too_many_items
400
차감 없음
한 구독 또는 배치 요청에 담을 수 있는 운송장 수를 넘었습니다. 응답의 max_items가 상한입니다.
대응 요청이나 구독을 나눠서 시도하세요.
subscription_limit_exceeded
409
차감 없음
동시에 추적 중인 운송장이 상한에 닿았습니다. 응답의 active가 현재 수입니다.
대응 끝난 구독을 해지하거나, 배송이 완료돼 자동으로 빠지기를 기다리세요. 상한 조정은 문의해 주세요.
subscription_not_found
404
차감 없음
그 구독이 없습니다. 다른 프로젝트의 구독도 똑같이 404입니다.
대응 GET /v1/webhooks로 이 키가 볼 수 있는 구독을 확인하세요.
tracking_number_not_found와 carrier_unavailable은 다릅니다. 전자는 택배사에 그 운송장이 없는 것(접수 직후일 수 있음), 후자는 택배사 시스템에 접근 자체가 안 되는 것입니다. 이 둘을 구분해야 무한 재시도와 잘못된 안내를 피할 수 있습니다.
GET /v1/openapi.json 내려받기
OpenAPI 3.1 스펙을 그대로 내려받습니다. 인증이 없으니 키를 발급받기 전에 SDK부터 만들어 봐도 됩니다.
npx @openapitools/openapi-generator-cli generate \
-i https://taekbae.net/v1/openapi.json \
-g typescript-fetch -o ./taekbae-sdk
필드 추가는 하위호환 변경으로 봅니다. 클라이언트는 모르는 필드를 무시하도록 만들어 주세요. 필드 제거나 의미 변경은 /v2로 가며, 폐기 예정은 Deprecation · Sunset 헤더로 미리 알립니다.