웹훅
웹훅은 알림의 전달 상태가 바뀔 때 Posmit이 고객사 서버로 이벤트를 push 하는 기능입니다. 발송 결과를 폴링하지 않아도 전달 완료·읽음·실패·취소를 실시간에 가깝게 받아볼 수 있습니다.
등록한 URL로 JSON payload가 POST 되고, 모든 요청에는 HMAC-SHA256 서명이 포함되어 위조 요청을 걸러낼 수 있습니다.
이벤트 타입
이벤트는 아래 4종입니다.
| 이벤트 | 발생 시점 |
|---|---|
notification.delivered | 알림이 수신자에게 전달 완료됨 (공급자 콜백 또는 상태 폴링으로 확인) |
notification.read | 수신자가 알림을 읽음 (읽음 확인을 지원하는 채널) |
notification.failed | 발송이 최종 실패함 (재시도 불가 또는 재시도 소진) |
notification.canceled | 알림이 취소됨 (API 취소 또는 공급자 측 취소) |
Payload 구조
{
"eventId": "8c1f0b9e-4d2a-4c7b-9f1e-2a6d8e5c3b71",
"eventType": "notification.delivered",
"workspaceId": "11111111-2222-3333-4444-555555555555",
"notificationRequestId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"status": "delivered",
"occurredAt": "2026-07-10T12:00:00.000Z",
"payload": {
"provider": "solapi_sms",
"externalEventId": "MSG-20260710-0001"
}
}| 필드 | 타입 | 설명 |
|---|---|---|
eventId | string (UUID) | 전송 건마다 고유. 멱등 처리 키로 사용하세요 |
eventType | string | 위 4종 이벤트 타입 중 하나 |
workspaceId | string (UUID) | 워크스페이스 ID |
notificationRequestId | string (UUID) | 원본 알림 요청 ID |
status | string | 발행 시점의 전달 상태 문자열 |
occurredAt | string | 이벤트 발생 시각 (ISO 8601) |
payload | object | 이벤트별 부가 정보 (문자열 맵) |
payload 내부 필드는 이벤트에 따라 다릅니다.
| 이벤트 | payload 필드 |
|---|---|
notification.delivered | provider, externalEventId(있는 경우) |
notification.read | provider, externalEventId, readConfirmed: "true" |
notification.failed | provider(콜백 유래 시), errorCode, errorMessage, externalEventId(있는 경우) |
notification.canceled | 공급자 취소: provider, externalEventId / API 취소: reason: "canceled_by_api" |
요청 헤더와 서명
웹훅 요청은 아래 헤더와 함께 POST 됩니다.
| 헤더 | 값 |
|---|---|
Content-Type | application/json |
X-ProjectNoti-Event | 이벤트 타입 (예: notification.delivered) |
X-ProjectNoti-Timestamp | 서명 시각. Unix epoch 초 (문자열) |
X-ProjectNoti-Signature | HMAC-SHA256 서명의 hex 문자열 |
User-Agent | project-noti-webhook/1.0 |
서명은 다음과 같이 계산됩니다.
signature = HMAC-SHA256(secret, "{timestamp}.{rawBody}") → hex 인코딩secret— 구독 생성(또는 시크릿 회전) 시 딱 한 번 발급되는 64자 hex 문자열timestamp—X-ProjectNoti-Timestamp헤더 값rawBody— 수신한 원문 요청 바디 (파싱 전 바이트 그대로)
반드시 원문 바디로 검증하세요. JSON을 파싱했다가 다시 직렬화하면 공백이나 키 순서가 달라져 서명이 일치하지 않을 수 있습니다. 프레임워크의 raw body 옵션으로 원본 바이트를 보관한 뒤 검증해야 합니다.
서명 검증 예시 (TypeScript / Express)
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const WEBHOOK_SECRET = process.env.POSMIT_WEBHOOK_SECRET!; // YOUR_HMAC_SECRET (64자 hex)
// 서명 검증을 위해 원문 바디를 그대로 받습니다
app.post(
'/webhooks/posmit',
express.raw({ type: 'application/json' }),
(req, res) => {
const timestamp = req.get('x-projectnoti-timestamp') ?? '';
const signature = req.get('x-projectnoti-signature') ?? '';
const rawBody = req.body as Buffer;
// 1. 서명 재계산: HMAC-SHA256(secret, "timestamp.rawBody")
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${timestamp}.${rawBody.toString('utf8')}`)
.digest('hex');
// 2. 상수 시간 비교 (타이밍 공격 방지)
const valid =
expected.length === signature.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
if (!valid) return res.sendStatus(401);
// 3. 리플레이 방지: timestamp 신선도 검사 (권장, 수신 측 책임)
const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(ageSeconds) || ageSeconds > 300) return res.sendStatus(401);
// 4. 검증 통과 후에만 파싱
const event = JSON.parse(rawBody.toString('utf8'));
// 5. eventId로 멱등 처리 (재시도로 같은 이벤트가 중복 도착할 수 있습니다)
// if (alreadyProcessed(event.eventId)) return res.sendStatus(200);
// 처리는 비동기로 넘기고 빠르게 2xx 응답
res.sendStatus(200);
handleEvent(event).catch(console.error);
},
);
async function handleEvent(event: { eventType: string; notificationRequestId: string }) {
switch (event.eventType) {
case 'notification.delivered':
// ...
break;
case 'notification.failed':
// ...
break;
}
}서명 timestamp는 재시도에도 최초 값이 그대로 유지됩니다. 신선도 검사 허용 범위를 정할 때 재시도 지연(기본 최대 약 3초 + 타임아웃)을 감안하되, 너무 좁게 잡지 않는 것을 권장합니다.
재시도 정책
| 항목 | 기본값 |
|---|---|
| 최대 시도 횟수 | 3회 |
| 백오프 | 지수 백오프 (1초 → 2초) |
| 요청 타임아웃 | 5초 |
| 성공 판정 | HTTP 2xx (200 이상 300 미만) |
- 2xx 외의 상태 코드, 네트워크 오류, 타임아웃은 모두 재시도 대상입니다.
- 모든 시도를 소진하면 해당 이벤트는 포기되고 로그만 남습니다. 별도의 지연 재전송 큐는 없으므로, 누락이 의심되면 알림 조회 API로 최종 상태를 확인하세요.
- 같은 이벤트는 조건에 맞는 모든 구독에 병렬로 전송됩니다.
- 웹훅 발행은 fire-and-forget 방식이라 발송 처리 흐름을 지연시키지 않습니다.
핸들러에서는 가능한 한 빠르게 2xx를 응답하세요.
처리에 5초 이상 걸리면 타임아웃으로 재시도가 발생해 같은 이벤트가 중복 도착합니다.
무거운 작업은 큐에 넣고 즉시 응답한 뒤, eventId 기준으로 멱등하게 처리하는 것을 권장합니다.
구독 등록 (대시보드)
웹훅 구독은 대시보드에서만 등록·관리합니다. Notification API 키로는 구독을 생성할 수 없습니다.
엔드포인트 준비
https:// URL로 POST를 받을 수 있는 엔드포인트를 준비합니다.
http / https 프로토콜만 허용됩니다.
구독 생성
대시보드의 웹훅 메뉴에서 이름, 대상 URL, 구독할 이벤트 타입을 입력해 구독을 생성합니다. 이벤트 타입은 1~10개를 선택할 수 있고, 생략하면 4종 전체를 구독합니다. 같은 워크스페이스에 동일한 URL은 중복 등록할 수 없습니다.
시크릿 보관
생성 응답에 서명 시크릿(32바이트 hex, 64자)이 딱 한 번 표시됩니다. 이후에는 다시 조회할 수 없으므로 즉시 안전한 곳(시크릿 매니저 등)에 보관하세요. 분실했다면 시크릿 회전으로 새 값을 발급받아야 합니다.
검증 코드 배포
위 서명 검증 예시를 참고해 수신 서버에 검증 로직을 배포하고, 실제 이벤트가 도착하는지 확인합니다.
구독 관리 API
대시보드 웹훅 메뉴의 작업은 아래 대시보드 API로 제공되며, 대시보드 액세스 토큰(Authorization: Bearer …)으로 직접 호출할 수도 있습니다.
토큰 발급과 워크스페이스 선택은 대시보드 인증을 참고하세요.
워크스페이스의 구독 목록을 생성 시각 오름차순으로 조회합니다. (역할: owner / admin / operator / viewer)
{
"webhooks": [
{
"id": "7f3b2a10-9c4d-4e5f-8a6b-1c2d3e4f5a6b",
"name": "배송 알림 수신",
"targetUrl": "https://example.com/webhooks/posmit",
"eventTypes": ["notification.delivered", "notification.failed"],
"enabled": true,
"status": "active",
"createdAt": "2026-07-10T09:00:00.000Z",
"updatedAt": "2026-07-10T09:00:00.000Z"
}
]
}enabled는 status === "active"와 동등한 하위호환 필드이므로 status를 사용하세요.
서명 시크릿은 어떤 조회 응답에도 포함되지 않습니다.
구독을 생성합니다. (역할: owner / admin / operator)
| 필드 | 타입 | 제약 |
|---|---|---|
name | string | 필수. 최대 120자 |
targetUrl | string | 필수. 프로토콜을 포함한 절대 URL(http / https만), 최대 2,048자. 같은 워크스페이스에 동일 URL은 중복 등록 불가 |
eventTypes | string[] | 선택. 위 4종 이벤트 타입 중 1~10개. 생략하면 4종 전체를 구독하며, 중복 값은 제거됩니다 |
enabled | boolean | 선택. 기본 true. false면 suspended 상태로 생성됩니다 |
curl -X POST https://api.posmit.io/v1/webhooks \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "배송 알림 수신",
"targetUrl": "https://example.com/webhooks/posmit",
"eventTypes": ["notification.delivered", "notification.failed"]
}'응답의 secret(64자 hex)은 이 응답에서 딱 한 번만 노출되며 이후 다시 조회할 수 없습니다.
{
"webhook": {
"id": "7f3b2a10-9c4d-4e5f-8a6b-1c2d3e4f5a6b",
"name": "배송 알림 수신",
"targetUrl": "https://example.com/webhooks/posmit",
"eventTypes": ["notification.delivered", "notification.failed"],
"enabled": true,
"status": "active",
"secret": "0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9",
"createdAt": "2026-07-10T09:00:00.000Z",
"updatedAt": "2026-07-10T09:00:00.000Z"
}
}구독을 수정하고, 모든 필드가 선택이며, 보낸 필드만 반영됩니다. (역할: owner / admin / operator)
| 필드 | 타입 | 제약 |
|---|---|---|
name | string | 최대 120자 |
targetUrl | string | 생성과 동일한 제약. 다른 구독과 중복되면 400 |
eventTypes | string[] | 1~10개 |
status | string | active 또는 suspended |
생성 시의 enabled 대신 수정에서는 status로 상태를 변경합니다.
suspended 상태의 구독으로는 이벤트가 전송되지 않습니다.
응답은 목록 조회와 같은 형태의 webhook 객체 한 건이며, secret은 포함되지 않습니다.
새 서명 시크릿을 발급하며, 요청 바디는 없습니다. (역할: owner / admin / operator)
{
"secret": "0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9"
}새 시크릿도 이 응답에서 한 번만 노출되며, 기존 시크릿은 즉시 무효화됩니다.
시크릿을 회전하면 이전 시크릿은 즉시 무효화됩니다. 무중단 교체가 필요하면 새 시크릿을 수신 서버에 먼저 배포할 수 있도록 트래픽이 적은 시간대에 회전하는 것을 권장합니다.
구독을 삭제하며, 성공 시 204 No Content(빈 바디)를 반환합니다. (역할: owner / admin / operator)
주요 에러
에러는 RFC 7807 application/problem+json 형식입니다.
| HTTP | 코드 | 원인 |
|---|---|---|
| 400 | NOTI-40000 | 필드 검증 실패(길이·개수 제약 위반, 허용되지 않은 필드, UUID가 아닌 경로 파라미터), targetUrl이 http/https 절대 URL이 아님, 같은 워크스페이스에 이미 등록된 targetUrl |
| 401 | NOTI-40100 | Bearer 액세스 토큰 누락·만료·무효 |
| 403 | NOTI-40300 | 워크스페이스 미선택(비바인딩) 토큰이거나 역할이 허용 목록에 없음 |
| 404 | NOTI-40400 | 해당 워크스페이스에 webhookSubscriptionId 구독이 없음 |