---
title: "웹훅 설정 가이드 | Tajo"
description: "Tajo 연동이 Brevo 이메일 및 연락처 이벤트를 받을 수 있도록 Brevo 웹훅을 설정하는 가이드"
url: https://tajo.io/kr/docs/webhook-configuration/setup-guide/
markdown: https://tajo.io/kr/docs/webhook-configuration/setup-guide.md
lang: ko-KR
author: "Set Noa"
author_url: https://tajo.io/kr/set-noa/
robots: noindex
---

Tajo 사전 이용 신청

이름과 회사 이메일 주소를 입력하세요. Tajo 이용 신청을 확인할 수 있는 링크를 이메일로 보내드립니다.

# 웹훅 설정 가이드

[Set Noa](https://tajo.io/kr/set-noa/)

Brevo 웹훅은 이메일 및 연락처 이벤트가 발생하면 Tajo 연동 등 사용자가 선택한 엔드포인트로 해당 이벤트를 전송합니다. 이 가이드는 전체 설정 과정을 단계별로 안내합니다.

## 개요

웹훅을 사용하면 다음과 같은 특정 이벤트가 발생했을 때 Brevo가 Tajo 애플리케이션에 자동으로 알릴 수 있습니다.

- **이메일 이벤트**: 전달 완료, 열람, 클릭, 반송
- **SMS 이벤트**: 발송, 전달 완료, 실패, 회신
- **연락처 이벤트**: 생성, 업데이트, 수신 거부
- **캠페인 이벤트**: 시작, 완료, 일시 중지

## 사전 준비

웹훅을 설정하기 전에 다음 항목을 갖추었는지 확인하십시오.

- 웹훅을 수신할 **HTTPS 엔드포인트** (SSL 필수)
- 서명 검증에 사용할 **웹훅 시크릿**
- HTTP POST 요청을 처리할 수 있는 **서버 환경**
- 웹훅에 접근할 수 있는 권한을 가진 **Brevo 계정**

## 1단계: 웹훅 엔드포인트 준비하기

### 웹훅 핸들러 생성

```javascript
import express from 'express';
import crypto from 'crypto';
import { TajoLoyaltyService } from './loyalty-service.js';

const app = express();
const loyaltyService = new TajoLoyaltyService();

// Middleware to capture raw body for signature verification
app.use('/webhooks/brevo', express.raw({
  type: 'application/json',
  limit: '10mb'
}));

// Main webhook handler
app.post('/webhooks/brevo', async (req, res) => {
  try {
    // Verify webhook signature
    const signature = req.headers['x-brevo-signature'];
    if (!verifyWebhookSignature(req.body, signature)) {
      console.warn('Invalid webhook signature received');
      return res.status(401).json({ error: 'Unauthorized: Invalid signature' });
    }

    // Parse webhook payload
    const event = JSON.parse(req.body.toString());
    console.log('Received webhook event:', event.event, event.email);

    // Route to appropriate handler
    await handleWebhookEvent(event);

    // Respond quickly (Brevo expects response within 5 seconds)
    res.status(200).json({
      success: true,
      eventId: event['message-id'],
      timestamp: new Date().toISOString()
    });

  } catch (error) {
    console.error('Webhook processing error:', error);
    res.status(500).json({
      error: 'Internal server error',
      message: error.message
    });
  }
});

// Signature verification function
function verifyWebhookSignature(payload, signature) {
  if (!process.env.BREVO_WEBHOOK_SECRET || !signature) {
    return false;
  }

  const expectedSignature = crypto
    .createHmac('sha256', process.env.BREVO_WEBHOOK_SECRET)
    .update(payload)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature.replace('sha256=', ''), 'hex'),
    Buffer.from(expectedSignature, 'hex')
  );
}

// Event router
async function handleWebhookEvent(event) {
  switch (event.event) {
    // Email events
    case 'delivered':
      await handleEmailDelivered(event);
      break;
    case 'opened':
      await handleEmailOpened(event);
      break;
    case 'clicked':
      await handleEmailClicked(event);
      break;
    case 'bounced':
    case 'hard_bounced':
      await handleEmailBounced(event);
      break;
    case 'spam':
      await handleEmailSpam(event);
      break;
    case 'unsubscribed':
      await handleEmailUnsubscribed(event);
      break;

    // SMS events
    case 'sms_delivered':
      await handleSMSDelivered(event);
      break;
    case 'sms_failed':
      await handleSMSFailed(event);
      break;
    case 'sms_reply':
      await handleSMSReply(event);
      break;

    // Contact events
    case 'contact_created':
      await handleContactCreated(event);
      break;
    case 'contact_updated':
      await handleContactUpdated(event);
      break;
    case 'list_addition':
      await handleListAddition(event);
      break;

    default:
      console.warn('Unhandled webhook event:', event.event);
  }
}
```

### 이메일 이벤트 핸들러

```javascript
// Handle email delivery confirmation
async function handleEmailDelivered(event) {
  const customerEmail = event.email;
  const messageId = event['message-id'];

  await loyaltyService.updateCustomerEngagement(customerEmail, {
    lastEmailDelivered: new Date(),
    emailDeliveryRate: 'increment'
  });

  // Log for analytics
  console.log(`Email delivered to ${customerEmail}: ${messageId}`);
}

// Handle email opens (key engagement metric)
async function handleEmailOpened(event) {
  const customerEmail = event.email;
  const subject = event.subject;
  const timestamp = new Date(event.ts * 1000);

  // Update customer engagement score
  await loyaltyService.updateCustomerEngagement(customerEmail, {
    lastEmailOpened: timestamp,
    emailOpenRate: 'increment',
    engagementScore: 'increase'
  });

  // Track loyalty campaign engagement
  if (event.tag?.includes('loyalty')) {
    await loyaltyService.trackLoyaltyEngagement(customerEmail, {
      event: 'email_opened',
      campaign: extractCampaignFromSubject(subject),
      timestamp: timestamp
    });
  }

  console.log(`Email opened by ${customerEmail}: "${subject}"`);
}

// Handle email clicks (high-value engagement)
async function handleEmailClicked(event) {
  const customerEmail = event.email;
  const clickedUrl = event.link;
  const timestamp = new Date(event.ts * 1000);

  // High-value engagement - boost customer score
  await loyaltyService.updateCustomerEngagement(customerEmail, {
    lastEmailClicked: timestamp,
    emailClickRate: 'increment',
    engagementScore: 'boost'
  });

  // Track reward page visits
  if (clickedUrl.includes('/rewards') || clickedUrl.includes('/loyalty')) {
    await loyaltyService.trackEvent(customerEmail, 'Rewards Page Visited', {
      source: 'email',
      referrer_url: clickedUrl,
      timestamp: timestamp
    });
  }

  console.log(`Email link clicked by ${customerEmail}: ${clickedUrl}`);
}

// Handle email bounces (delivery issues)
async function handleEmailBounced(event) {
  const customerEmail = event.email;
  const bounceReason = event.reason;
  const bounceType = event.event; // 'bounced' or 'hard_bounced'

  if (bounceType === 'hard_bounced') {
    // Hard bounce - email address invalid
    await loyaltyService.updateCustomerStatus(customerEmail, {
      emailStatus: 'invalid',
      emailBounced: true,
      bounceReason: bounceReason,
      lastBounce: new Date()
    });

    // Consider switching to SMS for critical notifications
    await loyaltyService.suggestAlternativeChannel(customerEmail, 'sms');
  } else {
    // Soft bounce - temporary issue
    await loyaltyService.updateCustomerEngagement(customerEmail, {
      emailBounceCount: 'increment',
      lastBounce: new Date()
    });
  }

  console.warn(`Email bounced for ${customerEmail}: ${bounceReason}`);
}

// Handle spam reports (reputation management)
async function handleEmailSpam(event) {
  const customerEmail = event.email;

  // Mark customer as unengaged to prevent future spam reports
  await loyaltyService.updateCustomerStatus(customerEmail, {
    emailStatus: 'spam_reported',
    marketingEnabled: false,
    lastSpamReport: new Date()
  });

  // Alert marketing team for review
  await loyaltyService.alertMarketing('spam_report', {
    email: customerEmail,
    campaign: event.tag
  });

  console.warn(`Spam reported by ${customerEmail}`);
}
```

### SMS 이벤트 핸들러

```javascript
// Handle SMS delivery confirmation
async function handleSMSDelivered(event) {
  const customerPhone = event.phone;
  const messageId = event['message-id'];

  await loyaltyService.updateCustomerEngagement(customerPhone, {
    lastSMSDelivered: new Date(),
    smsDeliveryRate: 'increment'
  });

  console.log(`SMS delivered to ${customerPhone}: ${messageId}`);
}

// Handle SMS failures
async function handleSMSFailed(event) {
  const customerPhone = event.phone;
  const failureReason = event.reason;

  await loyaltyService.updateCustomerStatus(customerPhone, {
    smsStatus: 'failed',
    smsFailureReason: failureReason,
    lastSMSFailure: new Date()
  });

  // If SMS fails, consider email as alternative
  const customer = await loyaltyService.getCustomerByPhone(customerPhone);
  if (customer?.email) {
    await loyaltyService.suggestAlternativeChannel(customer.email, 'email');
  }

  console.warn(`SMS failed for ${customerPhone}: ${failureReason}`);
}

// Handle SMS replies (two-way communication)
async function handleSMSReply(event) {
  const customerPhone = event.phone;
  const replyText = event.text.toLowerCase().trim();

  // Process common replies
  if (replyText === 'stop' || replyText === 'unsubscribe') {
    await loyaltyService.unsubscribeFromSMS(customerPhone);
  } else if (replyText === 'help' || replyText === 'info') {
    await loyaltyService.sendSMSHelp(customerPhone);
  } else {
    // Forward to customer service
    await loyaltyService.forwardSMSToSupport(customerPhone, replyText);
  }

  console.log(`SMS reply from ${customerPhone}: "${replyText}"`);
}
```

## 2단계: Brevo에서 웹훅 구성하기

### Brevo 대시보드에서 구성

1. **Brevo 계정에 로그인합니다**
2. **Developers > Webhooks로 이동합니다**
3. **“Add a new webhook”을 클릭합니다**
4. **웹훅 설정을 구성합니다.**

```json
{
  "url": "https://your-tajo-domain.com/webhooks/brevo",
  "description": "Tajo Loyalty Platform Integration",
  "events": [
    "delivered",
    "opened",
    "clicked",
    "bounced",
    "hard_bounced",
    "spam",
    "unsubscribed",
    "sms_delivered",
    "sms_failed",
    "sms_reply",
    "contact_created",
    "contact_updated"
  ]
}
```

### API로 구성

```javascript
import { WebhooksApi } from '@getbrevo/brevo';

async function createWebhook() {
  const webhooksApi = new WebhooksApi();

  const createWebhook = {
    url: 'https://your-tajo-domain.com/webhooks/brevo',
    description: 'Tajo Loyalty Platform Integration',
    events: [
      'delivered',
      'opened',
      'clicked',
      'bounced',
      'hard_bounced',
      'spam',
      'unsubscribed',
      'sms_delivered',
      'sms_failed',
      'sms_reply',
      'contact_created',
      'contact_updated'
    ]
  };

  try {
    const response = await webhooksApi.createWebhook(createWebhook);
    console.log('Webhook created successfully:', response.id);
    return response;
  } catch (error) {
    console.error('Error creating webhook:', error);
    throw error;
  }
}
```

## 3단계: 보안 구현하기

### 환경 변수

```bash
# .env file
BREVO_WEBHOOK_SECRET=your-super-secure-webhook-secret-here
BREVO_API_KEY=xkeysib-your-api-key-here
WEBHOOK_RATE_LIMIT=1000
WEBHOOK_TIMEOUT=5000
```

### 요청 한도 제한

```javascript
import rateLimit from 'express-rate-limit';

const webhookLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 1000, // Limit each IP to 1000 requests per windowMs
  message: 'Too many webhook requests from this IP',
  standardHeaders: true,
  legacyHeaders: false,
});

app.use('/webhooks/brevo', webhookLimiter);
```

### IP 허용 목록 (선택 사항)

```javascript
const brevoIPs = [
  '185.41.28.0/24',
  '185.41.29.0/24',
  '217.182.196.0/24'
];

function isBrevoIP(ip) {
  // Implement IP range checking
  return brevoIPs.some(range => ipInRange(ip, range));
}

app.use('/webhooks/brevo', (req, res, next) => {
  const clientIP = req.ip || req.connection.remoteAddress;

  if (process.env.NODE_ENV === 'production' && !isBrevoIP(clientIP)) {
    return res.status(403).json({ error: 'Forbidden: Invalid source IP' });
  }

  next();
});
```

## 4단계: 웹훅 테스트하기

### 테스트용 웹훅 핸들러

```javascript
// Test endpoint for webhook verification
app.post('/webhooks/brevo/test', (req, res) => {
  const testEvent = {
    event: 'test',
    email: 'test@example.com',
    'message-id': 'test-message-id',
    timestamp: Date.now() / 1000,
    tags: ['test', 'loyalty']
  };

  console.log('Test webhook received:', testEvent);

  res.status(200).json({
    success: true,
    message: 'Test webhook processed successfully',
    receivedAt: new Date().toISOString()
  });
});
```

### 수동 테스트

```bash
# Test webhook endpoint with curl
curl -X POST https://your-domain.com/webhooks/brevo/test \
  -H "Content-Type: application/json" \
  -H "X-Brevo-Signature: sha256=test-signature" \
  -d '{
    "event": "delivered",
    "email": "test@example.com",
    "message-id": "test-123",
    "ts": 1640995200
  }'
```

### 웹훅 검증

```javascript
class WebhookValidator {
  static validateEvent(event) {
    const required = ['event', 'email', 'message-id'];
    const missing = required.filter(field => !event[field]);

    if (missing.length > 0) {
      throw new Error(`Missing required fields: ${missing.join(', ')}`);
    }

    // Validate email format
    if (!this.isValidEmail(event.email)) {
      throw new Error('Invalid email format');
    }

    // Validate event type
    const validEvents = [
      'delivered', 'opened', 'clicked', 'bounced', 'hard_bounced',
      'spam', 'unsubscribed', 'sms_delivered', 'sms_failed'
    ];

    if (!validEvents.includes(event.event)) {
      throw new Error(`Invalid event type: ${event.event}`);
    }

    return true;
  }

  static isValidEmail(email) {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }
}
```

## 5단계: 모니터링과 로깅

### 웹훅 모니터링

```javascript
import winston from 'winston';

const webhookLogger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ filename: 'webhook-error.log', level: 'error' }),
    new winston.transports.File({ filename: 'webhook-combined.log' })
  ]
});

// Add monitoring middleware
app.use('/webhooks/brevo', (req, res, next) => {
  const startTime = Date.now();

  res.on('finish', () => {
    const duration = Date.now() - startTime;

    webhookLogger.info('Webhook processed', {
      method: req.method,
      url: req.url,
      statusCode: res.statusCode,
      duration: duration,
      userAgent: req.headers['user-agent'],
      contentLength: req.headers['content-length']
    });
  });

  next();
});
```

### 헬스 체크 엔드포인트

```javascript
app.get('/webhooks/brevo/health', async (req, res) => {
  const health = {
    status: 'healthy',
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
    version: process.env.npm_package_version,
    environment: process.env.NODE_ENV,
    checks: {
      database: await checkDatabaseConnection(),
      redis: await checkRedisConnection(),
      brevoAPI: await checkBrevoAPIConnection()
    }
  };

  const allHealthy = Object.values(health.checks).every(check => check.status === 'ok');

  res.status(allHealthy ? 200 : 503).json(health);
});
```

## 6단계: 오류 처리와 복구

### 재시도 로직

```javascript
class WebhookProcessor {
  constructor() {
    this.maxRetries = 3;
    this.retryDelay = 1000; // 1 second
  }

  async processEvent(event, retries = 0) {
    try {
      await this.handleEvent(event);
    } catch (error) {
      if (retries < this.maxRetries && this.isRetryableError(error)) {
        console.warn(`Webhook processing failed, retrying... (${retries + 1}/${this.maxRetries})`);

        await this.delay(this.retryDelay * Math.pow(2, retries)); // Exponential backoff
        return this.processEvent(event, retries + 1);
      }

      // Max retries reached or non-retryable error
      await this.handleFailedEvent(event, error);
      throw error;
    }
  }

  isRetryableError(error) {
    // Retry on temporary failures
    return error.code === 'ECONNRESET' ||
           error.code === 'ETIMEDOUT' ||
           (error.status >= 500 && error.status < 600);
  }

  async handleFailedEvent(event, error) {
    // Store failed event for manual review
    await loyaltyService.storeFailedEvent(event, error.message);

    // Alert operations team for critical events
    if (this.isCriticalEvent(event)) {
      await loyaltyService.alertOps('webhook_failure', {
        event: event.event,
        email: event.email,
        error: error.message
      });
    }
  }

  delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}
```

### 데드 레터 큐

```javascript
import Bull from 'bull';

const webhookQueue = new Bull('webhook processing', process.env.REDIS_URL);
const deadLetterQueue = new Bull('webhook failed', process.env.REDIS_URL);

webhookQueue.process(async (job) => {
  const { event } = job.data;
  await handleWebhookEvent(event);
});

webhookQueue.on('failed', async (job, err) => {
  console.error(`Webhook job failed: ${err.message}`);

  // Add to dead letter queue for manual processing
  await deadLetterQueue.add('failed webhook', {
    originalEvent: job.data.event,
    error: err.message,
    failedAt: new Date(),
    attempts: job.attemptsMade
  });
});
```

## 자주 발생하는 문제 해결

### 1. 서명 검증 실패

```javascript
// Debug signature verification
function debugSignature(payload, receivedSignature) {
  const expectedSignature = crypto
    .createHmac('sha256', process.env.BREVO_WEBHOOK_SECRET)
    .update(payload)
    .digest('hex');

  console.log('Received signature:', receivedSignature);
  console.log('Expected signature:', expectedSignature);
  console.log('Payload length:', payload.length);
  console.log('First 100 chars:', payload.slice(0, 100));

  return expectedSignature === receivedSignature.replace('sha256=', '');
}
```

### 2. 이벤트 누락

다음 코드로 현재 웹훅 설정이 올바른지 확인하십시오.

```javascript
async function auditWebhookConfig() {
  const webhooksApi = new WebhooksApi();

  try {
    const webhooks = await webhooksApi.getWebhooks();

    webhooks.webhooks.forEach(webhook => {
      console.log('Webhook ID:', webhook.id);
      console.log('URL:', webhook.url);
      console.log('Events:', webhook.events);
      console.log('Status:', webhook.is_enabled ? 'enabled' : 'disabled');
    });
  } catch (error) {
    console.error('Error auditing webhooks:', error);
  }
}
```

### 3. 높은 지연 시간

다음과 같이 웹훅 처리 과정을 비동기 방식으로 최적화하십시오.

```javascript
// Process webhooks asynchronously
app.post('/webhooks/brevo', async (req, res) => {
  // Verify signature quickly
  if (!verifyWebhookSignature(req.body, req.headers['x-brevo-signature'])) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  // Respond immediately
  res.status(200).json({ success: true });

  // Process event asynchronously
  const event = JSON.parse(req.body.toString());
  webhookQueue.add('process event', { event }, {
    attempts: 3,
    backoff: {
      type: 'exponential',
      delay: 2000
    }
  });
});
```

## 다음 단계

- **웹훅 보안 가이드**: 고급 보안 실무
- **[이벤트 유형 레퍼런스](https://tajo.io/kr/docs/webhooks-events/event-types)**: 전체 이벤트 문서
- **[웹훅 테스트](https://tajo.io/kr/docs/testing-debug/)**: 테스트 및 디버깅 가이드
- **웹훅 분석**: 웹훅 성능 모니터링
