사용 가이드

웹훅

팀 이벤트를 HTTPS 엔드포인트, Slack, Discord로 전달하고 서명된 요청을 검증하는 방법입니다.

팀마다 최대 10개의 목적지를 등록할 수 있습니다. 이벤트를 한 번 기록한 뒤, 발행 시점에 등록된 각 목적지로 독립적으로 전달합니다. 종류는 webhook(서명된 JSON), slack, discord입니다. 현재 이벤트 목록에는 지출 예산 임계값 알림이 있습니다.

콘솔에서 목적지 관리

팀의 Billing(청구) 페이지에서 Webhook destinations를 찾으세요. owner, member, billing은 목록을 볼 수 있습니다. owner 또는 billing은 종류를 고르고 이름·URL을 입력해 목적지를 추가하거나 기존 목적지를 삭제할 수 있습니다. 일반 webhook의 서명 secret은 생성 직후 한 번만 표시하므로 닫기 전에 저장하세요. 목록에서는 다시 볼 수 없으며 Slack·Discord URL은 가려서 표시합니다.

목적지 등록

팀의 owner, member, billing은 목적지 목록을 볼 수 있습니다. 생성·삭제는 owner, billing만 할 수 있으며 검증된 계정이 필요합니다. API 요청에는 접속 토큰을 사용하세요.

curl -X POST "https://api.nitroship.co/v1/teams/$TEAM_ID/webhooks" \
  -H "Authorization: Bearer $NITROSHIP_ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"kind":"webhook","name":"Spend alerts","url":"https://example.com/nitroship/events"}'

생성에 성공하면 201과 목적지 정보를 받습니다.

{
  "id": "<destination public ID>",
  "kind": "webhook",
  "name": "Spend alerts",
  "url": "https://example.com/nitroship/events",
  "created_at": "2026-10-04T12:00:00Z",
  "secret": "whsec_<base64 key>"
}

webhook의 secret은 즉시 저장하세요. 생성 응답에서만 반환하며 이후 조회에서는 볼 수 없습니다. whsec_ 뒤에 임의의 32바이트를 base64로 인코딩한 값입니다. Slack·Discord 목적지는 서명 secret을 반환하지 않습니다.

메서드와 경로결과
GET /v1/teams/{teamId}/webhooks200 {"items":[...]}, created_at 순서이며 secret은 없습니다.
POST /v1/teams/{teamId}/webhooks본문 {kind,name,url}; 201 목적지, webhook에만 secret을 추가합니다.
DELETE /v1/teams/{teamId}/webhooks/{webhookId}204, 응답 본문이 없습니다.

ID는 UUID 문자열이 아니라 나이트로쉽 공개 ID(canonical base62)입니다. 목적지는 id, kind, name, url, RFC3339 created_at을 가집니다. webhook은 전체 URL을 반환합니다. Slack·Discord URL은 자격 증명이므로 응답에서 https://<host>/…<last 4 chars> 형식으로 가립니다.

수정·secret 교체·테스트 전송 엔드포인트는 없습니다. 목적지를 바꾸려면 삭제한 뒤 새로 등록하세요. 목적지별 이벤트 필터는 없습니다.

이름·URL·오류

  • kind는 webhook, slack, discord 중 하나입니다.
  • name은 앞뒤 공백을 제거한 뒤 1–64자여야 합니다.
  • url은 최대 2,048자이며, https, 호스트가 필수이고 사용자 정보나 fragment가 없어야 합니다. 일반 엔드포인트는 전역 공개 IP로만 해석되어야 하며 로컬·사설 주소로는 전달하지 않습니다.
  • slack: 호스트가 정확히 hooks.slack.com이고 경로는 /services/로 시작해야 합니다.
  • discord: 호스트가 discord.com, discordapp.com, ptb.discord.com, canary.discord.com 중 하나이고 경로는 /api/webhooks/로 시작해야 합니다.
오류 코드HTTP 상태의미
invalid_id400공개 ID가 올바르지 않습니다.
invalid_webhook400종류, 공백 제거 후 이름, URL이 올바르지 않습니다.
webhook_limit409이미 팀에 목적지가 10개 있습니다.
not_found404리소스를 사용할 수 없습니다. 팀 비멤버·비활성 팀도 포함합니다.
forbidden403팀 역할 권한이 부족합니다.

Slack 연결

  1. Slack 앱 대시보드에서 워크스페이스용 앱을 만드세요.
  2. Incoming Webhooks를 켜고 Add New Webhook to Workspace에서 대상 채널을 승인하세요.
  3. incoming-webhook URL(https://hooks.slack.com/services/...)을 복사하세요. 위 POST 경로에 kind: "slack", 알아보기 쉬운 name과 함께 등록하세요.

전체 URL은 비밀로 보관하세요. 나이트로쉽은 text 대체 텍스트와 blocks를 보냅니다. 제목 header, 메시지 section, fields, 이벤트에 콘솔 URL이 있으면 링크/버튼이 포함됩니다. 사용자·파생 텍스트의 &, <, >는 이스케이프합니다. 아래의 서명된 JSON envelope가 아니라 Slack 전용 메시지입니다.

Discord 연결

  1. 대상 채널의 채널 편집 → 연동 → 웹훅을 여세요. 웹훅 관리 권한이 필요합니다.
  2. 웹훅을 만들고 웹훅 URL 복사를 선택하세요(https://discord.com/api/webhooks/...). kind: "discord", 알아보기 쉬운 name으로 등록하세요.

전체 URL은 비밀로 보관하세요. 나이트로쉽은 Nitroship 이름으로 전송하며 allowed_mentions: {"parse":[]}로 멘션을 막습니다. embed에는 제목, 설명, 선택적인 콘솔 URL, 이벤트 시각, inline fields가 포함됩니다. Slack·Discord는 이벤트에 저장된 사람이 읽는 메시지를 사용하므로 같은 이벤트를 각 채널에 맞게 표시합니다.

서명된 이벤트 envelope

일반 webhook 목적지는 Standard Webhooks를 따르는 Nitroship Webhooks v1을 받습니다.

{
  "id": "<event public ID>",
  "type": "billing.spend.threshold_reached",
  "timestamp": "2026-10-04T12:00:00Z",
  "team_id": "<team public ID>",
  "data": {
    "period_start": "2026-10-01T00:00:00Z",
    "threshold": 50,
    "budget_minor": 10000,
    "spend_minor": 5000,
    "currency": "USD",
    "currency_minor_digits": 2
  }
}

id, team_id는 공개 ID입니다. 본문의 timestamp는 전달 시도 시각이 아닌 RFC3339 이벤트 생성 시각입니다. 저장한 이벤트 데이터는 재시도해도 바뀌지 않습니다.

헤더값
Content-Typeapplication/json
User-AgentNitroship-Webhooks/1
webhook-id본문 id와 같은 이벤트 ID입니다.
webhook-timestamp이번 전달 시도의 Unix 초입니다.
webhook-signaturev1,<base64 HMAC-SHA256>

서명 검증

  1. JSON 파싱이나 재직렬화 전에 원본 요청 본문 바이트를 보관하세요.
  2. secret에서 whsec_를 제거하고 나머지를 base64로 디코딩하여 HMAC 키를 얻으세요.
  3. webhook-id + "." + webhook-timestamp + "." + 원본 본문의 HMAC-SHA256을 계산하세요.
  4. 전달된 v1 서명과 상수 시간으로 비교하세요. 검증이 실패하면 이벤트를 처리하지 마세요.

재생 공격을 줄이려면 시도 시각도 확인하세요. 아래 예제는 수신자 허용 오차 5분을 선택한 것이며 서버의 전달 만료 시간이 아닙니다. 수신 서버의 시계를 동기화하세요. 검증 후에는 예상 팀·이벤트 종류를 확인하고 이벤트 id로 처리 중복을 막으세요. 재시도는 같은 ID에 새 시도 시각·서명을 사용합니다.

Node.js

원본 본문을 Buffer로, 세 헤더 값을 문자열로 전달하세요.

import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyWebhook(rawBody, headers, secret, now = Date.now() / 1000) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];
  if (!id || !/^\d+$/.test(timestamp ?? '') || !signatures ||
      !secret.startsWith('whsec_')) return false;
  const seconds = Number(timestamp);
  if (!Number.isSafeInteger(seconds) || Math.abs(now - seconds) > 300) return false;
  const key = Buffer.from(secret.slice(6), 'base64');
  const expected = createHmac('sha256', key)
    .update(`${id}.${timestamp}.`).update(rawBody).digest();
  return signatures.split(/\s+/).some((entry) => {
    if (!entry.startsWith('v1,')) return false;
    const received = Buffer.from(entry.slice(3), 'base64');
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}

verifyWebhook이 true를 반환한 뒤에만 JSON.parse(rawBody.toString('utf8'))로 파싱하여 처리하세요.

Go

r.Body에서 읽은 원본 바이트, r.Header, 저장한 secret, time.Now()를 전달하세요.

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "net/http"
    "strconv"
    "strings"
    "time"
)

func verifyWebhook(body []byte, headers http.Header, secret string, now time.Time) bool {
    id := headers.Get("webhook-id")
    timestamp := headers.Get("webhook-timestamp")
    seconds, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil || id == "" || !strings.HasPrefix(secret, "whsec_") {
        return false
    }
    age := now.Sub(time.Unix(seconds, 0))
    if age < -5*time.Minute || age > 5*time.Minute {
        return false
    }
    key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
    if err != nil {
        return false
    }
    mac := hmac.New(sha256.New, key)
    mac.Write([]byte(id + "." + timestamp + "."))
    mac.Write(body)
    expected := mac.Sum(nil)
    for _, entry := range strings.Fields(headers.Get("webhook-signature")) {
        if !strings.HasPrefix(entry, "v1,") {
            continue
        }
        received, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(entry, "v1,"))
        if err == nil && hmac.Equal(received, expected) {
            return true
        }
    }
    return false
}

검증 성공 후에만 JSON을 디코딩하고 처리하세요. 이벤트를 내구성 있게 접수한 뒤 2xx를 반환하고, 중복 전달을 안전하게 처리하세요.

전달·재시도

발행자의 DB 트랜잭션 안에서 이벤트와 대기 중인 전달을 durable outbox에 함께 기록하며, 발행 중에는 네트워크 요청을 하지 않습니다. 같은 팀에서 같은 중복 제거 키로 다시 발행하면 no-op입니다. 나중에 등록한 목적지는 이미 발행된 이벤트를 받지 않습니다.

별도의 leader 전용 dispatcher가 worker 4개로 동작합니다. 최대 10초 간격으로 조회하고 next_attempt_at 순서로 기한이 된 전달 최대 64건을 claim합니다. 한 목적지의 실패가 다른 목적지를 막지 않습니다.

  • 모든 2xx 응답은 전달 성공입니다. 그 외 응답·네트워크 오류는 재시도합니다.
  • 재시도 간격은 min(1분 × 2^(attempts − 1), 1시간)입니다. 429의 Retry-After를 따르되 최대 1시간입니다.
  • 첫 시도가 아니라 이벤트 생성 후 72시간이 지나면 전달을 포기합니다.
  • 전달별 전체 deadline은 15초, HTTP client timeout은 10초, 연결 timeout은 5초입니다.
  • 전역 공개 IP만 사용하며 proxy·redirect를 사용하지 않습니다. 최종 HTTPS URL을 등록하세요.
  • leadership을 잃으면 진행 중인 요청을 취소하며 해당 전달은 대기 상태로 남습니다. 수신은 성공했지만 성공 기록이 남지 않은 경우의 중복을 처리해야 합니다.
  • 30일보다 오래된 이벤트는 모든 전달이 종료 상태(성공 또는 실패)가 되면 삭제합니다.

이벤트 목록

billing.spend.threshold_reached

청구형 플랜 팀의 트래픽·컴퓨트 종량제 사용액에서 플랜의 월 크레딧을 차감한 금액이 예산의 **50%, 75%, 100%**에 도달하면 발행합니다. 5분마다 확인합니다. 예산과 사용액은 크레딧이 아니라 팀의 청구 통화로 계산한 금액입니다. 플랫폼 요금과 Flat Rate CDN 단계 요금은 예산 사용액에서 제외합니다. 운영자가 지급한 크레딧은 이후 청구서 총액에서 차감하므로 이 사용액을 줄이지 않습니다. 목적지 등록은 사용량과 청구 설정과 별개입니다. PUT /v1/teams/{teamId}/billing/spend는 {budget_minor,pause_on_budget}만 받으며 GET/PUT은 {budget_minor,pause_on_budget,thresholds}를 반환합니다.

data 필드의미
period_startRFC3339 UTC 청구 주기 시작 시각입니다.
threshold비율 50, 75, 100 중 하나입니다.
budget_minor통화의 최소 단위로 나타낸 정수 예산입니다.
spend_minor발행 시점의 플랜 월 크레딧 차감 후 종량제 사용액이며, 통화의 최소 단위 정수입니다.
currency팀의 현재 청구 주기 통화이며 USD, KRW 같은 ISO 4217 코드입니다.
currency_minor_digits통화 최소 단위의 자릿수 정수(USD: 2, KRW: 0)이며 금액 표시에 사용합니다.

현재 다른 제품 이벤트는 없습니다. 목적지 등록만으로 예산이나 pause_on_budget을 설정하지 않습니다. 비청구 플랜에서도 지출 설정을 저장할 수 있지만 지출 확인·알림은 청구 플랜에만 적용합니다.

이전 지출 웹훅 마이그레이션

기존 지출 설정에 비어 있지 않은 webhook_url과 webhook_secret이 있으면 Spend alerts라는 webhook 목적지로 옮깁니다. 마이그레이션한 secret은 whsec_ 뒤에 기존 secret 바이트를 base64로 인코딩한 값이며 HMAC 키 바이트는 유지됩니다. 수신자 설정에는 기존 secret 바이트를 이 형식으로 인코딩하세요.

전송 형식은 호환 계층 없이 전환합니다. 이전 flat payload와 X-Nitroship-Signature 헤더는 제거되므로 위 envelope와 Standard Webhooks 서명 검증으로 수신자를 변경하세요. 지출 API는 더 이상 webhook_url, webhook_secret을 받거나 반환하지 않습니다. /v1/teams/{teamId}/webhooks에서 목적지를 관리하세요.

이 페이지에서