🔔 웹훅 연동 가이드
웹훅은 이벤트 발생 시 등록된 URL로 HTTP POST 요청을 보내 실시간으로 알림을 전달합니다. 각 요청에는 HMAC-SHA256 서명이 포함되어 있어 페이로드의 무결성과 출처를 검증할 수 있습니다.
📌 사전 준비
웹훅을 사용하려면 콘솔 > 개발자 > 웹훅 관리에서 웹훅 URL과 시크릿 키를 등록해야 합니다.
웹훅 관리에 대한 자세한 내용은 콘솔 매뉴얼을 참고하세요.
수신 HTTP 헤더
웹훅 요청에 포함되는 헤더 목록입니다.
| 헤더명 | 값 | 설명 |
|---|---|---|
| Content-Type | application/json | 페이로드 형식 |
| User-Agent | FingerpushLink-Webhook/1.0 | 핑거푸시 웹훅 에이전트 식별 |
| X-Webhook-Id | 웹훅 ID | 이벤트를 발생시킨 웹훅의 고유 ID |
| X-Webhook-Event | 이벤트 타입 | 이벤트 종류 (click, campaign_click, test 등) |
| X-Signature-256 | sha256={hex} | HMAC-SHA256 서명 (페이로드 검증용) |
| X-Delivery-Id | UUID | 개별 전송의 고유 식별자 (중복 수신 방지용) |
이벤트 타입
수신 가능한 이벤트 목록입니다. 웹훅 설정 시 원하는 이벤트만 선택하여 수신할 수 있습니다.
| 이벤트 타입 | 설명 |
|---|---|
| click | 단축 URL 클릭 이벤트 |
| campaign_click | 캠페인 URL 클릭 이벤트 |
| goal_completed | 딥링크 퍼널 목표 달성 이벤트 |
| funnel_stage_changed | 딥링크 퍼널 단계 전환 이벤트 |
| session_event | 크로스 플랫폼 세션 이벤트 |
| test | 테스트 이벤트 (콘솔에서 테스트 발송 시) |
공통 페이로드 구조
모든 이벤트는 동일한 래퍼 구조로 전송됩니다.
JSON — 공통 구조
{
"event": "이벤트 타입",
"timestamp": "2026-01-15T14:30:00+09:00",
"delivery_id": "550e8400-e29b-41d4-a716-446655440000",
"organization_id": 1,
"data": { ... 이벤트별 데이터 ... }
}| 필드 | 타입 | 설명 |
|---|---|---|
| event | String | 이벤트 타입 (위 표 참고) |
| timestamp | String | 이벤트 발생 시각 (ISO 8601, KST) |
| delivery_id | String | 전송 고유 ID (UUID, 중복 방지용) |
| organization_id | Number | 조직 ID |
| data | Object | 이벤트별 상세 데이터 |
이벤트별 페이로드 예제
JSON — click
{
"event": "click",
"timestamp": "2026-01-15T14:30:00+09:00",
"delivery_id": "550e8400-e29b-41d4-a716-446655440000",
"organization_id": 1,
"data": {
"url": {
"id": 123,
"short_url": "abc123",
"original_url": "https://example.com/landing",
"title": "랜딩 페이지"
},
"click": {
"ip_hash": "a1b2c3d4",
"country": "KR",
"browser": "Chrome",
"os": "Android",
"device_type": "mobile",
"referrer": "https://example.com/page"
}
}
}JSON — campaign_click
{
"event": "campaign_click",
"timestamp": "2026-01-15T14:30:00+09:00",
"delivery_id": "550e8400-e29b-41d4-a716-446655440000",
"organization_id": 1,
"data": {
"campaign": {
"id": 45,
"name": "신년 프로모션",
"alias": "new-year-2026",
"short_url": "c/new-year-2026"
},
"url": {
"id": 123,
"short_url": "abc123",
"original_url": "https://example.com/landing"
},
"click": {
"ip_hash": "a1b2c3d4",
"country": "KR",
"browser": "Chrome",
"os": "Android",
"device_type": "mobile",
"referrer": "https://example.com/page",
"cid": "promo-123",
"tracker_alias": "naver_blog"
}
}
}JSON — goal_completed
{
"event": "goal_completed",
"timestamp": "2026-01-15T14:35:00+09:00",
"delivery_id": "660e8400-e29b-41d4-a716-446655440000",
"organization_id": 1,
"data": {
"funnel": {
"id": 789,
"session_id": "sess-abc-123",
"external_user_id": "user-001",
"current_stage": "GOAL_COMPLETED",
"goal_type": "purchase",
"goal_value": 29900,
"platform": "android",
"device_type": "mobile",
"country_code": "KR",
"total_duration_seconds": 185
},
"ip_hash": "a1b2c3d4",
"url": {
"id": 123,
"short_url": "abc123",
"original_url": "https://example.com/landing"
},
"campaign": {
"id": 45,
"name": "신년 프로모션",
"alias": "new-year-2026"
}
}
}JSON — funnel_stage_changed
{
"event": "funnel_stage_changed",
"timestamp": "2026-01-15T14:32:00+09:00",
"delivery_id": "770e8400-e29b-41d4-a716-446655440000",
"organization_id": 1,
"data": {
"stage_transition": {
"previous_stage": "CLICKED",
"current_stage": "APP_OPENED"
},
"funnel": {
"id": 789,
"session_id": "sess-abc-123",
"external_user_id": "user-001",
"platform": "ios",
"device_type": "mobile",
"country_code": "KR"
}
}
}JSON — session_event
{
"event": "session_event",
"timestamp": "2026-01-15T14:33:00+09:00",
"delivery_id": "880e8400-e29b-41d4-a716-446655440000",
"organization_id": 1,
"data": {
"event": {
"id": 456,
"session_id": "sess-abc-123",
"event_type": "page_view",
"event_name": "상품 상세 페이지",
"created_at": "2026-01-15 14:33:00"
},
"event_data": {
"page_url": "https://example.com/product/123",
"product_id": "prod-123"
},
"session": {
"platform": "web",
"last_platform": "web",
"platform_transitions": 0,
"total_events": 5,
"external_user_id": "user-001"
}
}
}서명 검증
X-Signature-256 헤더의 HMAC-SHA256 서명을 검증하여 요청의 무결성과 출처를 확인합니다. 서명은 sha256={hex} 형식으로 전달됩니다.
⚠️ 보안 주의
서명 검증 없이 웹훅을 처리하면 위변조된 요청을 수신할 수 있습니다. 반드시 서명을 검증하세요.
Java — HMAC-SHA256 검증
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.HexFormat;
public boolean verifySignature(String payload, String secret, String signatureHeader) {
try {
String expected = signatureHeader.replace("sha256=", "");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes("UTF-8"), "HmacSHA256"));
String actual = HexFormat.of().formatHex(mac.doFinal(payload.getBytes("UTF-8")));
return MessageDigest.isEqual(expected.getBytes(), actual.getBytes());
} catch (Exception e) {
return false;
}
}Python — HMAC-SHA256 검증
import hmac
import hashlib
def verify_signature(payload: str, secret: str, signature_header: str) -> bool:
expected = signature_header.replace("sha256=", "")
actual = hmac.new(
secret.encode("utf-8"),
payload.encode("utf-8"),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, actual)Node.js — HMAC-SHA256 검증
const crypto = require('crypto');
function verifySignature(payload, secret, signatureHeader) {
const expected = signatureHeader.replace('sha256=', '');
const actual = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(actual, 'hex')
);
}PHP — HMAC-SHA256 검증
<?php
function verifySignature(string $payload, string $secret, string $signatureHeader): bool {
$expected = str_replace('sha256=', '', $signatureHeader);
$actual = hash_hmac('sha256', $payload, $secret);
return hash_equals($expected, $actual);
}
?>수신 서버 구축 예제
각 언어/프레임워크별 완전한 웹훅 수신 서버 예제입니다. 서명 검증, 중복 방지, 이벤트 라우팅을 포함합니다.
Java — Spring Boot 웹훅 수신 서버
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.HexFormat;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
@RestController
@RequestMapping("/webhook")
public class WebhookReceiverController {
private static final String WEBHOOK_SECRET = "your-webhook-secret";
private final ObjectMapper objectMapper = new ObjectMapper();
@PostMapping
public ResponseEntity<String> handleWebhook(
@RequestBody String payload,
@RequestHeader("X-Signature-256") String signature,
@RequestHeader("X-Webhook-Event") String eventType,
@RequestHeader("X-Delivery-Id") String deliveryId) {
// 1. 서명 검증
if (!verifySignature(payload, WEBHOOK_SECRET, signature)) {
return ResponseEntity.status(401).body("Invalid signature");
}
// 2. 중복 방지 (deliveryId 기반)
if (isDuplicate(deliveryId)) {
return ResponseEntity.ok("Already processed");
}
// 3. 빠르게 200 응답 후, 실제 처리는 비동기로
processAsync(eventType, payload);
return ResponseEntity.ok("OK");
}
@SuppressWarnings("unchecked")
private void processAsync(String eventType, String payload) {
// @Async 또는 별도 스레드로 처리 권장
try {
Map<String, Object> body = objectMapper.readValue(payload, Map.class);
Map<String, Object> data = (Map<String, Object>) body.get("data");
switch (eventType) {
case "click" -> handleClick(data);
case "campaign_click" -> handleCampaignClick(data);
case "goal_completed" -> handleGoalCompleted(data);
case "funnel_stage_changed" -> handleFunnelStageChanged(data);
case "session_event" -> handleSessionEvent(data);
}
} catch (Exception e) {
// 에러 로깅
}
}
@SuppressWarnings("unchecked")
private void handleClick(Map<String, Object> data) {
Map<String, Object> url = (Map<String, Object>) data.get("url");
Map<String, Object> click = (Map<String, Object>) data.get("click");
System.out.println("Click: " + url.get("short_url")
+ " from " + click.get("country"));
}
@SuppressWarnings("unchecked")
private void handleCampaignClick(Map<String, Object> data) {
Map<String, Object> campaign = (Map<String, Object>) data.get("campaign");
Map<String, Object> click = (Map<String, Object>) data.get("click");
System.out.println("Campaign: " + campaign.get("name")
+ ", CID: " + click.get("cid")
+ ", Tracker: " + click.get("tracker_alias"));
}
@SuppressWarnings("unchecked")
private void handleGoalCompleted(Map<String, Object> data) {
Map<String, Object> funnel = (Map<String, Object>) data.get("funnel");
System.out.println("Goal completed: " + funnel.get("goal_type")
+ ", Value: " + funnel.get("goal_value"));
}
@SuppressWarnings("unchecked")
private void handleFunnelStageChanged(Map<String, Object> data) {
Map<String, Object> transition = (Map<String, Object>) data.get("stage_transition");
System.out.println("Stage: " + transition.get("previous_stage")
+ " → " + transition.get("current_stage"));
}
@SuppressWarnings("unchecked")
private void handleSessionEvent(Map<String, Object> data) {
Map<String, Object> event = (Map<String, Object>) data.get("event");
Map<String, Object> session = (Map<String, Object>) data.get("session");
System.out.println("Session event: " + event.get("event_type")
+ ", Platform: " + session.get("platform")
+ ", Transitions: " + session.get("platform_transitions"));
}
private boolean verifySignature(String payload, String secret, String signatureHeader) {
try {
String expected = signatureHeader.replace("sha256=", "");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes("UTF-8"), "HmacSHA256"));
String actual = HexFormat.of().formatHex(mac.doFinal(payload.getBytes("UTF-8")));
return MessageDigest.isEqual(expected.getBytes(), actual.getBytes());
} catch (Exception e) {
return false;
}
}
private boolean isDuplicate(String deliveryId) {
// Redis 또는 DB로 중복 체크 구현
return false;
}
}Python — Flask 웹훅 수신 서버
from flask import Flask, request, jsonify
import hmac
import hashlib
app = Flask(__name__)
WEBHOOK_SECRET = "your-webhook-secret"
processed_deliveries = set() # 프로덕션에서는 Redis 사용 권장
def verify_signature(payload: bytes, secret: str, signature_header: str) -> bool:
expected = signature_header.replace("sha256=", "")
actual = hmac.new(
secret.encode("utf-8"),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, actual)
@app.route("/webhook", methods=["POST"])
def handle_webhook():
# 1. 서명 검증
signature = request.headers.get("X-Signature-256", "")
if not verify_signature(request.data, WEBHOOK_SECRET, signature):
return jsonify({"error": "Invalid signature"}), 401
# 2. 중복 방지
delivery_id = request.headers.get("X-Delivery-Id", "")
if delivery_id in processed_deliveries:
return jsonify({"status": "already processed"}), 200
processed_deliveries.add(delivery_id)
# 3. 이벤트 처리
event_type = request.headers.get("X-Webhook-Event", "")
body = request.get_json()
data = body.get("data", {})
handlers = {
"click": handle_click,
"campaign_click": handle_campaign_click,
"goal_completed": handle_goal_completed,
"funnel_stage_changed": handle_funnel_stage_changed,
"session_event": handle_session_event,
}
handler = handlers.get(event_type)
if handler:
handler(data)
return jsonify({"status": "ok"}), 200
def handle_click(data):
url_info = data.get("url", {})
click = data.get("click", {})
print(f"Click: {url_info.get('short_url')} from {click.get('country')}")
def handle_campaign_click(data):
campaign = data.get("campaign", {})
click = data.get("click", {})
print(f"Campaign: {campaign.get('name')}, "
f"CID: {click.get('cid')}, Tracker: {click.get('tracker_alias')}")
def handle_goal_completed(data):
funnel = data.get("funnel", {})
print(f"Goal: {funnel.get('goal_type')}, Value: {funnel.get('goal_value')}")
def handle_funnel_stage_changed(data):
transition = data.get("stage_transition", {})
print(f"Stage: {transition.get('previous_stage')} → {transition.get('current_stage')}")
def handle_session_event(data):
event = data.get("event", {})
session = data.get("session", {})
print(f"Session: {event.get('event_type')}, "
f"Platform: {session.get('platform')}, "
f"Transitions: {session.get('platform_transitions')}")
if __name__ == "__main__":
app.run(port=3000)Node.js — Express 웹훅 수신 서버
const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = 'your-webhook-secret';
const processedDeliveries = new Set(); // 프로덕션에서는 Redis 사용 권장
// raw body를 보존하기 위해 express.raw() 사용
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const payload = req.body.toString('utf8');
// 1. 서명 검증
const signature = req.headers['x-signature-256'] || '';
if (!verifySignature(payload, WEBHOOK_SECRET, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 2. 중복 방지
const deliveryId = req.headers['x-delivery-id'] || '';
if (processedDeliveries.has(deliveryId)) {
return res.json({ status: 'already processed' });
}
processedDeliveries.add(deliveryId);
// 3. 빠르게 응답 후 비동기 처리
res.json({ status: 'ok' });
const body = JSON.parse(payload);
const eventType = req.headers['x-webhook-event'] || '';
const data = body.data || {};
switch (eventType) {
case 'click':
console.log(`Click: ${data.url?.short_url} from ${data.click?.country}`);
break;
case 'campaign_click':
console.log(`Campaign: ${data.campaign?.name}, ` +
`CID: ${data.click?.cid}, Tracker: ${data.click?.tracker_alias}`);
break;
case 'goal_completed':
console.log(`Goal: ${data.funnel?.goal_type}, Value: ${data.funnel?.goal_value}`);
break;
case 'funnel_stage_changed':
console.log(`Stage: ${data.stage_transition?.previous_stage} → ` +
`${data.stage_transition?.current_stage}`);
break;
case 'session_event':
console.log(`Session: ${data.event?.event_type}, ` +
`Platform: ${data.session?.platform}, ` +
`Transitions: ${data.session?.platform_transitions}`);
break;
}
});
function verifySignature(payload, secret, signatureHeader) {
const expected = signatureHeader.replace('sha256=', '');
const actual = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
try {
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(actual, 'hex')
);
} catch (e) {
return false;
}
}
app.listen(3000, () => console.log('Webhook receiver on port 3000'));JSP — 웹훅 수신 서버
<%@ page contentType="application/json; charset=UTF-8" %>
<%@ page import="java.io.*, java.util.*, java.security.*, javax.crypto.*, javax.crypto.spec.*" %>
<%@ page import="com.google.gson.JsonObject, com.google.gson.JsonParser" %>
<%
// FingerpushLink 웹훅 수신 서버 (JSP)
// 필요: gson 라이브러리 (WEB-INF/lib/gson-x.x.x.jar)
final String WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET_HERE";
// 1. POST 메서드 확인
if (!"POST".equalsIgnoreCase(request.getMethod())) {
response.setStatus(405);
out.print("{\"error\": \"Method Not Allowed\"}");
return;
}
// 2. 요청 본문 읽기
StringBuilder sb = new StringBuilder();
BufferedReader reader = request.getReader();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
String body = sb.toString();
// 3. 서명 검증
String signatureHeader = request.getHeader("X-Signature-256");
if (signatureHeader == null || signatureHeader.isEmpty()) {
response.setStatus(401);
out.print("{\"error\": \"Missing signature\"}");
return;
}
String signature = signatureHeader.replace("sha256=", "");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(WEBHOOK_SECRET.getBytes("UTF-8"), "HmacSHA256"));
byte[] hash = mac.doFinal(body.getBytes("UTF-8"));
StringBuilder hexStr = new StringBuilder();
for (byte b : hash) {
hexStr.append(String.format("%02x", b));
}
String expected = hexStr.toString();
if (!MessageDigest.isEqual(expected.getBytes(), signature.getBytes())) {
response.setStatus(401);
out.print("{\"error\": \"Invalid signature\"}");
return;
}
// 4. 중복 방지 (Delivery ID 확인)
String deliveryId = request.getHeader("X-Delivery-Id");
// 실제 프로젝트에서는 DB/Redis에 저장하여 중복 체크
// 5. 이벤트 처리
String eventType = request.getHeader("X-Webhook-Event");
JsonObject payload = JsonParser.parseString(body).getAsJsonObject();
JsonObject data = payload.getAsJsonObject("data");
switch (eventType != null ? eventType : "") {
case "click":
String shortUrl = data.getAsJsonObject("url").get("short_url").getAsString();
application.log("[click] shortUrl=" + shortUrl);
break;
case "campaign_click":
String alias = data.getAsJsonObject("campaign").get("alias").getAsString();
application.log("[campaign_click] alias=" + alias);
break;
case "goal_completed":
JsonObject funnel = data.getAsJsonObject("funnel");
application.log("[goal_completed] goalType=" + funnel.get("goal_type").getAsString());
break;
case "session_event":
JsonObject event = data.getAsJsonObject("event");
application.log("[session_event] eventName=" + event.get("event_name").getAsString());
break;
case "funnel_stage_changed":
JsonObject transition = data.getAsJsonObject("stage_transition");
application.log("[funnel_stage_changed] " +
transition.get("previous_stage").getAsString() +
" -> " + transition.get("current_stage").getAsString());
break;
}
// 6. 성공 응답
response.setStatus(200);
out.print("{\"status\": \"ok\", \"deliveryId\": \"" + deliveryId + "\"}");
%>PHP — 웹훅 수신 서버
<?php
// FingerpushLink 웹훅 수신 서버 (PHP)
// PHP 7.4+ 필요, 추가 라이브러리 불필요
header('Content-Type: application/json; charset=UTF-8');
$WEBHOOK_SECRET = 'YOUR_WEBHOOK_SECRET_HERE';
// 1. POST 메서드 확인
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method Not Allowed']);
exit;
}
// 2. 요청 본문 읽기
$body = file_get_contents('php://input');
if (empty($body)) {
http_response_code(400);
echo json_encode(['error' => 'Empty body']);
exit;
}
// 3. 서명 검증
$signatureHeader = $_SERVER['HTTP_X_SIGNATURE_256'] ?? '';
if (empty($signatureHeader)) {
http_response_code(401);
echo json_encode(['error' => 'Missing signature']);
exit;
}
$signature = str_replace('sha256=', '', $signatureHeader);
$expected = hash_hmac('sha256', $body, $WEBHOOK_SECRET);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
// 4. 중복 방지 (Delivery ID 확인)
$deliveryId = $_SERVER['HTTP_X_DELIVERY_ID'] ?? '';
// 실제 프로젝트: DB/Redis에 저장 후 중복 체크
// 5. 이벤트 타입별 처리
$eventType = $_SERVER['HTTP_X_WEBHOOK_EVENT'] ?? '';
$payload = json_decode($body, true);
$data = $payload['data'];
switch ($eventType) {
case 'click':
$url = $data['url'];
error_log("[클릭] shortUrl={$url['short_url']}");
break;
case 'campaign_click':
$campaign = $data['campaign'];
$click = $data['click'];
error_log("[캠페인] alias={$campaign['alias']}, cid={$click['cid']}");
break;
case 'goal_completed':
$funnel = $data['funnel'];
error_log("[목표달성] goalType={$funnel['goal_type']}");
break;
case 'session_event':
$event = $data['event'];
error_log("[세션이벤트] eventName={$event['event_name']}");
break;
case 'funnel_stage_changed':
$transition = $data['stage_transition'];
error_log("[퍼널변경] {$transition['previous_stage']} -> {$transition['current_stage']}");
break;
}
// 6. 성공 응답
http_response_code(200);
echo json_encode([
'status' => 'ok',
'deliveryId' => $deliveryId,
]);
?>재시도 정책
| 항목 | 값 |
|---|---|
| 최대 재시도 횟수 | 최대 5회 (설정 가능, 기본: 3회) |
| 성공 조건 | 200~299 범위의 HTTP 상태 코드 |
| 자동 비활성화 | 연속 50회 실패 시 웹훅 자동 비활성화 |
| 타임아웃 | 기본 5000ms (1000~30000ms 설정 가능) |
베스트 프랙티스
💡 웹훅 운영 권장사항
- 빠른 응답 — 수신 서버는 200 OK를 빠르게 응답하고, 실제 처리는 비동기로 수행하세요.
- 중복 방지 — X-Delivery-Id를 저장하여 중복 수신을 방지하세요.
- 서명 검증 필수 — X-Signature-256 헤더를 반드시 검증하여 위변조를 방지하세요.
- 시크릿 관리 — 시크릿 키는 안전하게 보관하고, 노출 시 즉시 재생성하세요.
- 타임아웃 설정 — 수신 서버의 타임아웃은 웹훅 타임아웃보다 짧게 설정하세요.
- 필터링 — 필터 기능을 활용하여 필요한 이벤트만 수신하면 불필요한 트래픽을 줄일 수 있습니다.