개발자

웹훅

주문이 움직이거나 상품이 바뀌는 순간 서버로 이벤트를 POST하므로 폴링할 필요가 없어요. 서명되고, 약 하루 동안 재시도하며, API의 모든 경로에서 같아요.
이 페이지의 내용
  1. 1엔드포인트 설정
  2. 2이벤트
  3. 3이벤트
  4. 4서명
  5. 5전송 검증
  6. 6재시도와 순서

엔드포인트 설정

  1. 원본 본문을 읽고 10초 이내에 2xx로 응답하는 https 경로를 서버에 추가하세요. 작업은 응답한 뒤에 하세요.
  2. API로 엔드포인트를 만들거나 저희 팀에 요청하세요. 반환되는 whsec_ 시크릿을 저장하세요. 한 번만 표시돼요.
  3. 모든 요청을 검증한 뒤(아래), 이벤트 id로 중복을 제거하세요.
  4. 처리하는 각 유형의 테스트 이벤트를 직접 보내 보세요.

이벤트

모든 이벤트는 같은 봉투 구조예요. 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.

order.created
{
  "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

주문이 잔액에서 결제되었어요. 충전을 기다리던 주문에도 발생해요.

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예요.

order.bought
{
  "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

상품이 저희 창고에 있고, 사진과 함께 검수를 마쳤어요. 다음은 포장이에요.

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

택배가 송장 번호와 함께 출고되어 배송비가 확정되었어요. 주문이 들어온 스토어에도 송장 정보가 있어요.

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

판매자가 가격을 바꿨고, 동기화 규칙이 한 일: 가격 변경, 일시 중지, 알림.

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으로 설정했어요.

product.out_of_stock
{
  "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까지 선택하세요. 응답이 없으면 보류하고 연락드려요.

qc.issue
{
  "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-signaturev1, 쉼표, 그리고 시크릿으로 계산한 id.timestamp.body의 base64 HMAC-SHA256. 교체 중에는 공백으로 구분된 두 개.

키는 whsec_ 뒤의 시크릿을 base64 디코딩한 것이에요. 도착한 원본 본문에 그대로 서명하고, 5분 넘게 차이 나는 타임스탬프는 거부해 가로챈 요청을 재사용하지 못하게 하세요.

전송 검증

standardwebhooks 라이브러리로, 또는 직접 몇 줄로:

// npm install standardwebhooks express
import express from "express"
import { Webhook } from "standardwebhooks"

const webhook = new Webhook(process.env.NINJABUY_WEBHOOK_SECRET) // whsec_...
const app = express()

// The raw body: a parsed and re-serialised body no longer matches.
app.post("/hooks/ninjabuy", express.raw({ type: "application/json" }), (req, res) => {
  let event
  try {
    event = webhook.verify(req.body.toString("utf8"), req.headers)
  } catch {
    return res.sendStatus(400)
  }
  res.sendStatus(204) // answer first, then do the work
  queue.push(event) // deduplicate on event.id: a retry repeats it
})

재시도와 순서

10초 이내에 2xx가 아니면 실패로 보고 다시 시도해요. 매번 같은 webhook-id에 새 타임스탬프와 서명을 붙여요:

시도이전 시도 이후
1즉시
25초
35분
430분
52시간
65시간
710시간
810시간, 마지막
  • 마지막 시도 후에는 전송이 실패로 처리돼요. 전송 목록에서 다시 보내세요.
  • 5일 동안 모든 전송이 실패한 엔드포인트는 이메일로 알린 뒤 비활성화돼요. 다시 활성화하면 최근 3일 동안 놓친 것을 보내요.
  • 이벤트는 두 번 오거나 순서가 바뀌어 올 수 있어요. id로 중복을 제거하고, 순서가 중요하면 객체를 조회해 이벤트가 아닌 객체의 상태를 믿으세요.