Webhooks

Veacon → 고객 endpoint 로의 outbound 이벤트 전송. HMAC-SHA256 서명, 7 이벤트 타입 모두 live (3 subscription + 2 quota + 2 key), idempotent delivery log, 통합 검증용 test event, paginated delivery history.

Last updated: 2026-04-27

Veacon 이 발생시키는 이벤트를 customer-registered HTTPS endpoint 로 POST 합니다. Stripe / GitHub 패턴의 HMAC SHA256 서명. P10.

등록

Dashboard 의 /dashboard/webhooks 에서 endpoint 등록. 또는 API:

POST /api/auth/me/webhooks

json
{
  "url": "https://example.com/veacon/hooks",
  "description": "분석 시스템 - 분기 보고서 트리거",
  "events": ["subscription.updated", "quota.warning_80pct"]
}

응답:

json
{
  "data": {
    "id": "uuid-...",
    "url": "https://example.com/veacon/hooks",
    "secret_plaintext": "whsec_<64-hex>",
    "description": "...",
    "events": ["subscription.updated", "quota.warning_80pct"],
    "status": "active",
    "created_at": "2026-04-26T10:00:00Z"
  }
}

secret_plaintext 는 응답에 한 번만 나옵니다. 안전한 곳에 저장하세요. 분실 시 endpoint 재발급 (revoke + create) 필요.

이벤트 타입

이벤트설명v1 발신?
*모든 이벤트 (와일드카드)wildcard subscription
subscription.updatedStripe subscription state 변경 (created / updated 모두)Stripe webhook fan-out
subscription.cancelled구독 해지Stripe webhook fan-out
subscription.trial_will_endTrial 종료 3일 전 (Stripe 표준 신호)Stripe webhook fan-out
quota.warning_80pct월 quota 80% 도달 (월 1회)Internal-emit on threshold crossing
quota.exceeded월 quota 100% 도달 (월 1회)Internal-emit on threshold crossing
key.rotatedAPI key 회전 (self-serve 또는 admin)Internal-emit on rotate
key.revokedAPI key 회수 (self-serve 또는 admin)Internal-emit on revoke

v1 live (full surface): 7개 모든 이벤트가 live — 3 subscription.* (Stripe webhook fan-out), 2 quota.* (internal-emit on threshold crossing), 2 key.* (internal-emit on rotate/revoke). 와일드카드 * 는 모두 구독.

subscription.cancelled payload

json
{
  "id":      "evt_<stripe>",
  "type":    "subscription.cancelled",
  "created": 1714200000,
  "data": {
    "subscription": {
      "id":                   "sub_...",
      "status":               "canceled",
      "canceled_at":          "2026-04-27T10:00:00Z",
      "cancel_at_period_end": false,
      "current_period_end":   "2026-05-27T00:00:00Z"
    }
  }
}

quota.warning_80pct + quota.exceeded payload

두 이벤트는 동일 payload 형식을 공유합니다 — usage.current, usage.limit, usage.percent, usage.period_starts_at. 차이는 임계치 (80% vs 100%) 와 type 필드뿐.

월 누적 사용량이 임계치를 처음 넘는 호출에서 발사됩니다. 각 임계치 별로 period 당 정확히 1회 (race-safe — SELECT FOR UPDATE row lock 으로 동시 호출도 한 번만 fire). 단일 호출이 두 임계 모두 cross 할 수 있음 (예: 작은 quota 에서 used 4→5, quota 5 → 80%(4) + 100%(5) 동시 fire). 월 경계가 넘어가 lazy-reset 이 발동하면 두 anchor 모두 청소되어 다음 월 재발사 가능.

eventId 는 deterministic per (consumer, period, eventType):

  • evt_quota_warning_80pct_<consumer_uuid>_<period_iso>
  • evt_quota_exceeded_<consumer_uuid>_<period_iso>

webhook_deliveries (endpoint_id, event_id) unique constraint 가 second-layer idempotency 제공.

warning_80pct 예시:

json
{
  "id":      "evt_quota_warning_80pct_<uuid>_2026-04-01T00:00:00.000Z",
  "type":    "quota.warning_80pct",
  "created": 1714200000,
  "data": {
    "usage": {
      "current":          8000,
      "limit":            10000,
      "percent":          80.0,
      "period_starts_at": "2026-04-01T00:00:00.000Z"
    }
  }
}

exceeded 예시:

json
{
  "id":      "evt_quota_exceeded_<uuid>_2026-04-01T00:00:00.000Z",
  "type":    "quota.exceeded",
  "created": 1714200000,
  "data": {
    "usage": {
      "current":          10000,
      "limit":            10000,
      "percent":          100.0,
      "period_starts_at": "2026-04-01T00:00:00.000Z"
    }
  }
}

수신측 권장 조치:

  • warning_80pct: buyer's billing 알림, ETL 재계획, Pricing 에서 plan 상향 사전 검토
  • exceeded: 즉시 plan 상향 또는 다음 period 까지 wait. 이 시점부터 customer-facing API 는 402 QUOTA_EXCEEDED 반환

자세한 폴링 패턴 / /api/auth/me/usage 와의 비교는 Usage API 참조.

subscription.trial_will_end payload

Stripe 가 trial 만료 3일 전에 보내는 표준 신호. cohort 1 (12-month free trial) 의 경우 month-11.9 시점에 발사되어 buyer 의 billing/finance 자동화가 conversion 준비를 시작할 수 있습니다.

json
{
  "id":      "evt_<stripe>",
  "type":    "subscription.trial_will_end",
  "created": 1714200000,
  "data": {
    "subscription": {
      "id":                 "sub_...",
      "status":             "trialing",
      "trial_end":          "2027-04-25T00:00:00Z",
      "current_period_end": "2027-04-25T00:00:00Z"
    }
  }
}

key.rotated + key.revoked payload

API key 회전/회수 시 발사. actor 필드가 self-serve (buyer 가 dashboard 에서 직접) 와 admin (founder 가 보안 사유로 강제 회전/회수) 을 구분 — 보안 audit 신호.

eventId 는 evt_<eventTypeSlug>_<keyId>_<eventAtIso>. 같은 키의 여러 회전 이벤트가 시각으로 구별됩니다.

key.rotated 예시 (admin):

json
{
  "id":      "evt_key_rotated_<keyId>_2026-04-27T10:00:00.000Z",
  "type":    "key.rotated",
  "created": 1714200000,
  "data": {
    "key":            { "id": "<old-key-uuid>", "prefix": "veacon_pk_live_aaaa..." },
    "actor":          "admin",
    "event_at":       "2026-04-27T10:00:00.000Z",
    "new_key_id":     "<new-key-uuid>",
    "new_key_prefix": "veacon_pk_live_bbbb...",
    "grace_until":    "2026-04-28T10:00:00.000Z"
  }
}

key.revoked 예시 (self-serve):

json
{
  "id":      "evt_key_revoked_<keyId>_2026-04-27T11:00:00.000Z",
  "type":    "key.revoked",
  "created": 1714200000,
  "data": {
    "key":        { "id": "<key-uuid>", "prefix": null },
    "actor":      "self",
    "event_at":   "2026-04-27T11:00:00.000Z",
    "revoked_at": "2026-04-27T11:00:00.000Z"
  }
}

수신측 권장 조치:

  • actor: 'admin' — 보안팀 즉시 알림. Founder 가 의심 유출/위반 사유로 강제 회전/회수했을 가능성. CRM/Slack 보안 채널 즉시 ping
  • actor: 'self' — 일반 audit 로그. 변경 추적용
  • key.rotatedgrace_until 까지 old key 도 동작. CI/배포 파이프라인 secret 갱신 작업 시작
  • key.revoked — 즉시 old key 사용 중단. 401 응답 시작

key.rotated 의 event 는 회전된 OLD key 를 참조합니다 (Stripe 의 customer.subscription.updated 패턴). 새 key 는 payload.new_key_id / new_key_prefix 에 별도 surface.

페이로드 형식

모든 이벤트는 동일 envelope:

json
{
  "id":      "evt_<stripe_or_uuid>",
  "type":    "subscription.updated",
  "created": 1714200000,
  "data": {
    "subscription": {
      "id": "sub_...",
      "status": "active",
      "current_period_end": "2027-04-26T00:00:00Z",
      "trial_end": null
    }
  }
}

id 는 idempotency key — 같은 이벤트는 한 번만 처리하도록 클라이언트 측에서 dedupe.

서명 검증

HTTP 요청 헤더:

X-Veacon-Event:     subscription.updated
X-Veacon-Event-Id:  evt_...
X-Veacon-Signature: t=<unix_ts>,v1=<hex_sha256>
User-Agent:         Veacon-Webhooks/1.0

서명은 ${ts}.${raw_body} 를 endpoint secret 로 HMAC-SHA256 한 hex 값.

Node.js 검증

javascript
import crypto from 'node:crypto';

function verify(rawBody, signatureHeader, secret) {
  const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(signatureHeader);
  if (!m) return false;
  const [, ts, sig] = m;

  // 5분 replay 윈도우
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

  const expected = crypto
    .createHmac('sha256', Buffer.from(secret.replace('whsec_', ''), 'hex'))
    .update(`${ts}.${rawBody}`)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(sig, 'hex'),
    Buffer.from(expected, 'hex'),
  );
}

// Express 예시
app.post('/veacon/hooks', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.header('X-Veacon-Signature');
  if (!verify(req.body, sig, process.env.VEACON_WEBHOOK_SECRET)) {
    return res.status(400).send('invalid signature');
  }
  const event = JSON.parse(req.body);
  // process event...
  res.status(200).send();
});

Python 검증

python
import hmac, hashlib, time
from binascii import unhexlify

def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
    parts = dict(p.split('=') for p in signature_header.split(','))
    ts, sig = parts.get('t'), parts.get('v1')
    if not ts or not sig:
        return False
    # 5min replay window
    if abs(time.time() - int(ts)) > 300:
        return False
    key = unhexlify(secret.replace('whsec_', ''))
    expected = hmac.new(
        key, f"{ts}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(sig, expected)

응답 + 재시도 정책

  • HTTP 200-299 → delivered 로 기록
  • 그 외 / timeout (5초) → failed 로 기록 + consecutive_failures 증가
  • consecutive_failures > 10 이면 endpoint 가 자동으로 disabled_after_failure 로 전환 — 추가 발신 안 됨, dashboard 에서 수동 재활성화 필요.
  • v1 은 재시도 없음 (best-effort). 재시도 큐는 cohort 1 traffic 후 별도 PR.

Test event 발송 — 통합 검증

Production Stripe 이벤트가 떨어지길 기다리지 않고 자체 receiver 의 HMAC 검증 로직을 즉시 확인할 수 있습니다. Stripe 의 "Send test event" 와 동일한 패턴.

Dashboard

/dashboard/webhooks 에서 active endpoint 의 Test 발송 버튼 클릭 → 즉시 결과 표시 (HTTP 상태 + duration_ms).

API

POST /api/auth/me/webhooks/{id}/test

응답:

json
{
  "data": {
    "test": true,
    "event_id": "evt_test_<uuid>",
    "event_type": "webhook.test",
    "delivery": {
      "status": "delivered",
      "http_status": 200,
      "duration_ms": 142,
      "error": null
    }
  }
}

Test event 의 envelope 은 production 이벤트와 byte-for-byte 동일 — 같은 서명 스킴, 같은 헤더, 같은 body 구조. Test 가 성공하면 production 에서도 작동합니다.

json
{
  "id": "evt_test_<uuid>",
  "type": "webhook.test",
  "created": 1714200000,
  "data": {
    "test": true,
    "message": "This is a Veacon test event...",
    "sent_at": "2026-04-27T10:00:00Z",
    "docs_url": "https://veacon.io/docs/api/webhooks"
  }
}

규칙:

  • Endpoint 가 active 상태일 때만 가능. revoked / disabled_after_failure 는 409.
  • 구독 이벤트 필터를 우회 — endpoint 가 subscription.updated 만 구독해도 test 는 항상 발송 (Stripe 동일 컨벤션).
  • Test 도 webhook_deliveries 로그에 기록 — consecutive_failures 카운터가 영향받지 않도록 receiver 가 200 을 반환해야 함. (수신측 버그 디버깅 중이라면 의도적으로 비-200 을 반환하면 production endpoint 가 disable 될 수 있음을 유의.)

Delivery 이력 — self-debug

Production failure 시 buyer 가 직접 진단 가능. "Veacon 이 보냈는가? 받은 쪽에서 어떻게 응답했는가?" 단일 round-trip 답변.

GET /api/auth/me/webhooks/{id}/deliveries

쿼리 파라미터:

이름기본범위설명
limit501–200페이지 크기
offset0≥ 0시작 위치
bash
curl 'https://veacon.io/api/auth/me/webhooks/<endpoint_id>/deliveries?limit=50' \
  -H 'Cookie: veacon_session=<your_session_jwt>'

응답:

json
{
  "data": {
    "rows": [
      {
        "id": "uuid-...",
        "event_type": "subscription.updated",
        "event_id": "evt_1Q...",
        "status": "delivered",
        "http_status": 200,
        "duration_ms": 142,
        "error_message": null,
        "attempt_count": 1,
        "created_at": "2026-04-27T10:00:00Z"
      },
      {
        "id": "uuid-...",
        "event_type": "webhook.test",
        "event_id": "evt_test_...",
        "status": "failed",
        "http_status": 502,
        "duration_ms": 4920,
        "error_message": "HTTP 502",
        "attempt_count": 1,
        "created_at": "2026-04-27T09:55:00Z"
      }
    ],
    "pagination": {
      "limit": 50,
      "offset": 0,
      "returned": 2
    },
    "summary": {
      "total_count": 2,
      "delivered_24h": 1,
      "failed_24h": 1
    }
  }
}

보안 + 프라이버시:

  • 본인 소유의 endpoint 만. 다른 consumer 의 UUID 를 추측해 넣어도 빈 배열 + 0 카운트 반환 (정보 누출 방지).
  • payload 필드는 list 응답에서 제외 — 구독 이벤트 페이로드는 잠재적으로 민감한 정보를 담을 수 있어, 다인 화면 공유 시 우발적 노출 방지. status / http_status / error / duration / timestamp 만으로도 모든 디버깅 시나리오 커버.
  • Self-call 은 monthly_request_quota 에 카운트되지 않습니다.

일반 진단 패턴:

증상확인
Endpoint 가 갑자기 동작 안 함summary.failed_24h 가 0 이 아니면 → endpoint 다운. 마지막 error_message 로 원인 추적.
Veacon 이 안 보낸 줄 알았음rows[0].status === 'delivered' 이면 → Veacon 보냄. 받은 쪽 (서명 검증 / 처리 로직) 에 버그.
webhook.test 가 안 도착함event_type 으로 필터: rows.filter(r => r.event_type === 'webhook.test')
Disabled 이유failed_24h 가 10 가까우면 → consecutive_failures 누적, 자동 disable 임박.

관리

작업메서드URL
목록GET/api/auth/me/webhooks
등록POST/api/auth/me/webhooks
해지DELETE/api/auth/me/webhooks/{id}
Test 발송POST/api/auth/me/webhooks/{id}/test
Delivery 이력GET/api/auth/me/webhooks/{id}/deliveries

해지는 soft revoke — status='revoked' 로 표시되고 audit trail 보존. 재활성화 불가, 새로 등록.