Webhooks
Veacon → 고객 endpoint 로의 outbound 이벤트 전송. HMAC-SHA256 서명, 7 이벤트 타입 모두 live (3 subscription + 2 quota + 2 key), idempotent delivery log, 통합 검증용 test event, paginated delivery history.
Veacon 이 발생시키는 이벤트를 customer-registered HTTPS endpoint 로 POST 합니다. Stripe / GitHub 패턴의 HMAC SHA256 서명. P10.
등록
Dashboard 의 /dashboard/webhooks 에서 endpoint 등록. 또는 API:
POST /api/auth/me/webhooks
{
"url": "https://example.com/veacon/hooks",
"description": "분석 시스템 - 분기 보고서 트리거",
"events": ["subscription.updated", "quota.warning_80pct"]
}
응답:
{
"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.updated | Stripe subscription state 변경 (created / updated 모두) | Stripe webhook fan-out |
subscription.cancelled | 구독 해지 | Stripe webhook fan-out |
subscription.trial_will_end | Trial 종료 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.rotated | API key 회전 (self-serve 또는 admin) | Internal-emit on rotate |
key.revoked | API 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
{
"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 예시:
{
"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 예시:
{
"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 준비를 시작할 수 있습니다.
{
"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):
{
"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):
{
"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 보안 채널 즉시 pingactor: 'self'— 일반 audit 로그. 변경 추적용key.rotated—grace_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:
{
"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 검증
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 검증
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
응답:
{
"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 에서도 작동합니다.
{
"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
쿼리 파라미터:
| 이름 | 기본 | 범위 | 설명 |
|---|---|---|---|
limit | 50 | 1–200 | 페이지 크기 |
offset | 0 | ≥ 0 | 시작 위치 |
curl 'https://veacon.io/api/auth/me/webhooks/<endpoint_id>/deliveries?limit=50' \
-H 'Cookie: veacon_session=<your_session_jwt>'
응답:
{
"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 보존. 재활성화 불가, 새로 등록.