دليل إعداد Webhook
تتيح webhooks تواصلًا لحظيًا بين Brevo ومنصّة الولاء Tajo لديك. يرشدك هذا الدليل إلى عملية الإعداد كاملة.
نظرة عامة
تسمح webhooks لـ Brevo بإشعار تطبيق Tajo لديك تلقائيًا عند وقوع أحداث محدّدة، مثل:
- أحداث البريد الإلكتروني: تم التسليم، الفتح، النقر، الارتداد
- أحداث SMS: الإرسال، التسليم، الفشل، الرد
- أحداث جهات الاتصال: الإنشاء، التحديث، إلغاء الاشتراك
- أحداث الحملات: البدء، الاكتمال، الإيقاف المؤقّت
المتطلبات المسبقة
قبل إعداد webhooks، تأكّد من توفّر ما يلي:
- نقطة نهاية HTTPS لاستقبال webhooks (SSL مطلوب)
- مفتاح webhook السري للتحقّق من التوقيع
- بيئة خادم قادرة على معالجة طلبات HTTP POST
- حساب Brevo يملك صلاحيات الوصول إلى webhooks
الخطوة 1: جهّز نقطة نهاية webhook
إنشاء معالج webhook
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 verificationapp.use('/webhooks/brevo', express.raw({ type: 'application/json', limit: '10mb'}));
// Main webhook handlerapp.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 functionfunction 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 routerasync 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); }}معالجات أحداث البريد الإلكتروني
// Handle email delivery confirmationasync 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
// Handle SMS delivery confirmationasync 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 failuresasync 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: اضبط webhook في Brevo
عبر لوحة تحكم Brevo
- سجّل الدخول إلى حساب Brevo
- انتقل إلى Developers > Webhooks
- انقر على “Add a new webhook”
- اضبط إعدادات webhook:
{ "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
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: تطبيق إجراءات الأمان
متغيّرات البيئة
# .env fileBREVO_WEBHOOK_SECRET=your-super-secure-webhook-secret-hereBREVO_API_KEY=xkeysib-your-api-key-hereWEBHOOK_RATE_LIMIT=1000WEBHOOK_TIMEOUT=5000تحديد معدّل الطلبات
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 المسموح بها (اختياري)
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: اختبار webhooks
معالج اختبار webhook
// Test endpoint for webhook verificationapp.post('/webhooks/brevo/test', (req, res) => { const testEvent = { event: 'test', '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() });});الاختبار اليدوي
# Test webhook endpoint with curlcurl -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": "[email protected]", "message-id": "test-123", "ts": 1640995200 }'التحقّق من صحّة webhook
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: المراقبة والتسجيل
مراقبة webhook
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 middlewareapp.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();});نقطة نهاية فحص السلامة
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: معالجة الأخطاء والتعافي
منطق إعادة المحاولة
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)); }}طابور الرسائل المعطّلة
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. فشل التحقّق من التوقيع
// Debug signature verificationfunction 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. أحداث مفقودة
تحقّق من إعدادات webhook:
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. زمن استجابة مرتفع
حسّن معالجة webhook:
// Process webhooks asynchronouslyapp.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 } });});الخطوات التالية
- دليل أمان Webhook، ممارسات أمان متقدّمة
- مرجع أنواع الأحداث، توثيق كامل للأحداث
- اختبار webhooks، دليل الاختبار وتصحيح الأخطاء
- تحليلات Webhook، مراقبة أداء webhook