개발자
웹훅
주문이 움직이거나 상품이 바뀌는 순간 서버로 이벤트를 POST하므로 폴링할 필요가 없어요. 서명되고, 약 하루 동안 재시도하며, API의 모든 경로에서 같아요.
엔드포인트 설정
이벤트
모든 이벤트는 같은 봉투 구조예요. data에 유형별 필드가 들어가요.
idstring- evt_로 시작해요. 모든 재시도에서 같으며 webhook-id 헤더에도 들어가요.
typestring- 아래 이벤트 중 하나.
api_versionstring- data가 작성되는 버전: 엔드포인트의 버전.
created_atstring- 발생한 시각. 받기 전일 수도 있어요.
testboolean- 테스트 이벤트면 true.
dataobject- 전체 객체를 조회할 ID와 변경된 내용.
POST /hooks/ninjabuy HTTP/1.1
Host: acme-goods.com
Content-Type: application/json
User-Agent: NinjaBuy-Webhooks/1.0
webhook-id: evt_4m2x
webhook-timestamp: 1793685600
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"id": "evt_4m2x",
"type": "order.shipped",
"api_version": "2026-11",
"created_at": "2026-11-03T06:00:00Z",
"test": false,
"data": {
"order": "ord_3n7q",
"external_id": "1012",
"shipment": "shp_5r8w",
"carrier": "YunExpress",
"tracking_number": "YT2630812345678",
"tracking_url": "https://t.17track.net/en#nums=YT2630812345678",
"shipping_charged": { "amount": "134.52", "currency": "CNY" },
"pushed_to_channel": true
}
}이벤트
order.created
Shopify 스토어나 API에서 주문이 저희에게 도착했어요. paid 또는 awaiting_payment.
{
"id": "evt_1a7c",
"type": "order.created",
"api_version": "2026-11",
"created_at": "2026-11-01T09:14:02Z",
"test": false,
"data": {
"order": "ord_3n7q",
"external_id": "1012",
"source": "shopify",
"status": "buying",
"payment_status": "paid",
"lines": [
{
"line": "lin_9a2c",
"variant": "var_41hz",
"sku": "5187209554",
"quantity": 1
}
]
}
}order.paid
주문이 잔액에서 결제되었어요. 충전을 기다리던 주문에도 발생해요.
{
"id": "evt_1a9f",
"type": "order.paid",
"api_version": "2026-11",
"created_at": "2026-11-01T11:02:40Z",
"test": false,
"data": {
"order": "ord_3n8a",
"external_id": "1013",
"amount": { "amount": "57.00", "currency": "CNY" },
"paid_from": "balance",
"was_awaiting_payment": true
}
}order.bought
판매자에게 결제했어요. 저희 창고에 도착할 때까지 항목은 with_seller예요.
{
"id": "evt_2c4e",
"type": "order.bought",
"api_version": "2026-11",
"created_at": "2026-11-01T13:30:05Z",
"test": false,
"data": {
"order": "ord_3n7q",
"external_id": "1012",
"lines": [
{
"line": "lin_9a2c",
"sku": "5187209554",
"quantity": 1,
"status": "with_seller"
}
]
}
}order.checked
상품이 저희 창고에 있고, 사진과 함께 검수를 마쳤어요. 다음은 포장이에요.
{
"id": "evt_9w1c",
"type": "order.checked",
"api_version": "2026-11",
"created_at": "2026-11-02T03:20:11Z",
"test": false,
"data": {
"order": "ord_3n7q",
"external_id": "1012",
"fulfillment_order": "fo_7k2m",
"lines": [
{
"line": "lin_9a2c",
"qc": "passed",
"photos": [
"https://media.ninjabuy.com/qc/ord_3n7q/1.webp",
"https://media.ninjabuy.com/qc/ord_3n7q/2.webp"
]
}
]
}
}order.shipped
택배가 송장 번호와 함께 출고되어 배송비가 확정되었어요. 주문이 들어온 스토어에도 송장 정보가 있어요.
{
"id": "evt_4m2x",
"type": "order.shipped",
"api_version": "2026-11",
"created_at": "2026-11-03T06:00:00Z",
"test": false,
"data": {
"order": "ord_3n7q",
"external_id": "1012",
"shipment": "shp_5r8w",
"carrier": "YunExpress",
"tracking_number": "YT2630812345678",
"tracking_url": "https://t.17track.net/en#nums=YT2630812345678",
"shipping_charged": { "amount": "134.52", "currency": "CNY" },
"pushed_to_channel": true
}
}product.cost_changed
판매자가 가격을 바꿨고, 동기화 규칙이 한 일: 가격 변경, 일시 중지, 알림.
{
"id": "evt_8q0d",
"type": "product.cost_changed",
"api_version": "2026-11",
"created_at": "2026-11-04T02:10:00Z",
"test": false,
"data": {
"product": "prd_1x0a",
"variant": "var_2b7k",
"sku": "4410923871",
"cost": {
"from": { "amount": "42.00", "currency": "CNY" },
"to": { "amount": "48.00", "currency": "CNY" }
},
"action": "repriced",
"price": {
"from": { "amount": "18.99", "currency": "USD" },
"to": { "amount": "20.99", "currency": "USD" }
},
"rule": "rule_default"
}
}product.out_of_stock
판매자의 옵션이 품절되었고 내 선반에도 없어요: 채널에서 0으로 설정했어요.
{
"id": "evt_2b9n",
"type": "product.out_of_stock",
"api_version": "2026-11",
"created_at": "2026-11-04T02:11:30Z",
"test": false,
"data": {
"product": "prd_8f2k",
"variant": "var_41j0",
"sku": "5187209555",
"warehouse_on_hand": 0,
"channels_updated": [
{
"channel": "shopify",
"shop": "acme-goods.myshopify.com",
"available": 0
}
]
}
}qc.issue
검수에서 상품에 문제가 발견되었어요: 색상이나 사이즈 오류, 손상, 부품 누락. respond_by까지 선택하세요. 응답이 없으면 보류하고 연락드려요.
{
"id": "evt_6c3h",
"type": "qc.issue",
"api_version": "2026-11",
"created_at": "2026-11-02T03:20:11Z",
"test": false,
"data": {
"order": "ord_3n7q",
"external_id": "1012",
"fulfillment_order": "fo_7k2m",
"line": "lin_9a2c",
"sku": "5187209554",
"issue": "wrong_color",
"note": "Received grey, ordered black.",
"photos": ["https://media.ninjabuy.com/qc/ord_3n7q/3.webp"],
"choices": ["ship_anyway", "exchange", "refund"],
"respond_by": "2026-11-05T03:20:11Z"
}
}서명
전송은 Standard Webhooks 방식으로 서명되므로 이미 쓰고 있는 라이브러리로 검증할 수 있어요. 모든 전송에 헤더 세 개가 붙어요:
| 헤더 | 보관하는 것 |
|---|---|
| webhook-id | 이벤트 ID. |
| webhook-timestamp | 이 시도를 보낸 시각(Unix 초). |
| webhook-signature | v1, 쉼표, 그리고 시크릿으로 계산한 id.timestamp.body의 base64 HMAC-SHA256. 교체 중에는 공백으로 구분된 두 개. |
키는 whsec_ 뒤의 시크릿을 base64 디코딩한 것이에요. 도착한 원본 본문에 그대로 서명하고, 5분 넘게 차이 나는 타임스탬프는 거부해 가로챈 요청을 재사용하지 못하게 하세요.
전송 검증
standardwebhooks 라이브러리로, 또는 직접 몇 줄로:
재시도와 순서
10초 이내에 2xx가 아니면 실패로 보고 다시 시도해요. 매번 같은 webhook-id에 새 타임스탬프와 서명을 붙여요:
| 시도 | 이전 시도 이후 |
|---|---|
| 1 | 즉시 |
| 2 | 5초 |
| 3 | 5분 |
| 4 | 30분 |
| 5 | 2시간 |
| 6 | 5시간 |
| 7 | 10시간 |
| 8 | 10시간, 마지막 |
- 마지막 시도 후에는 전송이 실패로 처리돼요. 전송 목록에서 다시 보내세요.
- 5일 동안 모든 전송이 실패한 엔드포인트는 이메일로 알린 뒤 비활성화돼요. 다시 활성화하면 최근 3일 동안 놓친 것을 보내요.
- 이벤트는 두 번 오거나 순서가 바뀌어 올 수 있어요. id로 중복을 제거하고, 순서가 중요하면 객체를 조회해 이벤트가 아닌 객체의 상태를 믿으세요.