Webhook System
Quick Access: Receive real-time notifications about email events, campaign activities, and system updates via HTTP webhooks.
Overview
The Webhook System enables external applications to receive instant notifications when events occur in PenguinMails, powering integrations with CRMs, analytics tools, marketing automation platforms, and custom applications.
Key Capabilities
-
Real-Time Events: Instant HTTP POST notifications
-
Event Filtering: Subscribe only to relevant events
-
Retry Logic: Automatic retries on delivery failures
-
Signature Verification: Cryptographic signatures for security
-
Event Replay: Retrieve historical events
-
Testing Tools: Webhook debugger and request inspector
Level 1: Quick Start Guide
Create Your First Webhook
Step 1: Navigate to Webhooks
Dashboard β Settings β Integrations β Webhooks β Create Webhook
Step 2: Configure Webhook
Basic Configuration:
Webhook Name: CRM Contact Sync
Endpoint URL: https://yourapp.com/webhooks/penguinmails
Description: Sync email engagement to CRM
Status: β Active β Paused
Step 3: Select Events
Choose which events to receive:
Email Events:
β email.sent
β email.delivered
β email.opened
β email.clicked
β email.bounced
β email.spam_reported
β email.unsubscribed
Campaign Events:
β campaign.launched
β campaign.completed
β campaign.paused
Contact Events:
β contact.created
β contact.updated
β contact.unsubscribed
Step 4: Test Webhook
[Send Test Event]
Test payload will be sent to:
https://yourapp.com/webhooks/penguinmails
Sending test event...
Γ’Εβ Test successful! (Response: 200 OK)
Step 5: Save & Activate
[Save Webhook]
Γ’Εβ Webhook created and activated
Secret Key: whsec_abc123... [Copy]
Add this to your application for signature verification.
Receiving Webhook Events
Example Payload (Email Opened):
{
"id": "evt_abc123",
"type": "email.opened",
"created_at": "2025-11-25T14:30:00Z",
"data": {
"email_id": "msg_xyz789",
"campaign_id": "camp_def456",
"contact": {
"id": "cont_ghi012",
"email": "user@example.com",
"first_name": "Sarah",
"last_name": "Johnson"
},
"opened_at": "2025-11-25T14:30:00Z",
"user_agent": "Mozilla/5.0...",
"ip_address": "192.168.1.1",
"location": {
"city": "New York",
"region": "NY",
"country": "US"
}
}
}
Handling Webhooks in Your Application
Node.js Example:
const express = require('express');
const crypto = require('crypto');
app.post('/webhooks/penguinmails', express.json(), (req, res) => {
// 1. Verify signature
const signature = req.headers['x-penguinmails-signature'];
if (!verifySignature(req.body, signature)) {
return res.status(401).send('Invalid signature');
}
// 2. Handle event
const event = req.body;
switch (event.type) {
case 'email.opened':
console.log(`Email opened by ${event.data.contact.email}`);
// Update your CRM, trigger workflow, etc.
break;
case 'email.clicked':
console.log(`Link clicked: ${event.data.url}`);
break;
// ... other events
}
// 3. Respond quickly (< 5 seconds)
res.status(200).send('OK');
});
function verifySignature(payload, signature) {
const secret = process.env.WEBHOOK_SECRET;
const hmac = crypto.createHmac('sha256', secret);
hmac.update(JSON.stringify(payload));
const digest = `sha256=${hmac.digest('hex')}`;
return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature));
}
Level 2: Advanced Webhook Configuration
Event Filtering
Filter by workspace:
Webhook Name: Client A Events Only
Event Filters:
Workspaces: [Client A]
Events: [email.opened, email.clicked]
Filter by campaign:
Event Filters:
Campaign ID: camp_xyz123
Events: [All]
Custom filters (JSON):
{
"event_types": ["email.opened", "email.clicked"],
"filters": {
"contact.tags": ["vip", "premium"],
"campaign.type": "promotional"
}
}
Retry Logic & Error Handling
Automatic Retries:
If webhook delivery fails:
Retry #1: After 1 minute
Retry #2: After 5 minutes
Retry #3: After 15 minutes
Retry #4: After 1 hour
Retry #5: After 24 hours
After 5 failed attempts:
- Mark webhook as "failing"
- Send email notification to admin
- Pause webhook after 100 consecutive failures
View Failed Deliveries:
Recent Deliveries:
Γ’Εβ email.opened 200 OK Nov 25, 14:30
Γ’Εβ email.clicked 200 OK Nov 25, 14:29
Γ’Εβ email.bounced 500 Error Nov 25, 14:28 [Retry #2]
[View Failed Event] [Retry Now] [Disable Webhook]
Signature Verification
Why Important:
-
Prevents spoofed webhook requests
-
Ensures data integrity
-
Verifies sender authenticity
Verification Process:
1. PenguinMails generates HMAC-SHA256 signature
2. Signature sent in X-PenguinMails-Signature header
3. Your app recomputes signature with shared secret
4. Compare signatures (use timing-safe comparison)
TypeScript/Node.js Example:
import express from 'express';
import crypto from 'crypto';
import bodyParser from 'body-parser';
const app = express();
// Middleware to get raw body for signature verification
app.use('/webhooks', bodyParser.json({
verify: (req: any, res: any, buf: Buffer) => {
req.rawBody = buf.toString('utf8');
}
}));
interface WebhookEvent {
id: string;
type: string;
created_at: string;
data: unknown;
}
interface WebhookHandler {
verifyWebhook(payload: string, signature: string, secret: string): boolean;
handleWebhook(req: express.Request, res: express.Response): Promise<void>;
}
class WebhookHandlerImpl implements WebhookHandler {
private webhookSecret: string;
constructor(secret: string) {
this.webhookSecret = secret;
}
verifyWebhook(payload: string, signature: string, secret: string): boolean {
try {
const computed = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
const expected = `sha256=${computed}`;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
} catch (error) {
console.error('Webhook signature verification error:', error);
return false;
}
}
async handleWebhook(req: express.Request, res: express.Response): Promise<void> {
try {
const signature = req.headers['x-penguinmails-signature'] as string;
const rawBody = (req as any).rawBody;
if (!signature) {
res.status(401).json({ error: 'Missing signature header' });
return;
}
// Verify signature
if (!this.verifyWebhook(rawBody, signature, this.webhookSecret)) {
res.status(401).json({ error: 'Invalid signature' });
return;
}
const event: WebhookEvent = req.body;
// Process event based on type
switch (event.type) {
case 'email.opened':
await this.handleEmailOpened(event);
break;
case 'email.clicked':
await this.handleEmailClicked(event);
break;
case 'email.bounced':
await this.handleEmailBounced(event);
break;
case 'email.sent':
await this.handleEmailSent(event);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
res.status(200).json({ success: true });
} catch (error) {
console.error('Webhook processing error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
private async handleEmailOpened(event: WebhookEvent): Promise<void> {
console.log(`Email opened by ${event.data.contact?.email}`);
// Update your CRM, trigger workflow, etc.
// Example: await crmService.updateContactEngagement(event.data);
}
private async handleEmailClicked(event: WebhookEvent): Promise<void> {
console.log(`Link clicked: ${event.data.url}`);
// Track click event, update analytics, etc.
}
private async handleEmailBounced(event: WebhookEvent): Promise<void> {
console.log(`Email bounced: ${event.data.email_id}`);
// Handle bounce logic - update contact status, etc.
}
private async handleEmailSent(event: WebhookEvent): Promise<void> {
console.log(`Email sent: ${event.data.email_id}`);
// Update sending status, trigger follow-up workflows, etc.
}
}
// Usage example
const webhookSecret = process.env.WEBHOOK_SECRET || 'your-webhook-secret';
const webhookHandler = new WebhookHandlerImpl(webhookSecret);
// Express route
app.post('/webhooks', webhookHandler.handleWebhook.bind(webhookHandler));
// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Webhook server listening on port ${PORT}`);
});
// Alternative: Pure JavaScript implementation
function verifyWebhookJavaScript(payload, signature, secret) {
const computed = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
const expected = `sha256=${computed}`;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
// Standalone verification function
function standaloneVerify(payload, signature, secret) {
if (!payload || !signature || !secret) {
return false;
}
try {
return verifyWebhookJavaScript(payload, signature, secret);
} catch (error) {
console.error('Signature verification failed:', error);
return false;
}
}
Webhook Debugging
Request Inspector:
Last 50 Webhook Deliveries:
[Nov 25, 14:30:15] email.opened
β POST https://yourapp.com/webhooks
Status: 200 OK
Duration: 142ms
[View Request] [View Response]
Request Headers:
Content-Type: application/json
X-PenguinMails-Signature: sha256=abc123...
X-PenguinMails-Event: email.opened
User-Agent: PenguinMails-Webhooks/1.0
Request Body:
{ "id": "evt_...", ... }
Response:
200 OK
Content-Type: text/plain
Body: "OK"
Test Mode:
[Send Test Event]
Choose test event type:
β email.opened
β email.clicked
β email.bounced
Use real data from:
Campaign: [Select Campaign Γ’βΒΌ]
Contact: [Select Contact Γ’βΒΌ]
OR
Use sample data β
[Send Test]
Event Replay
Replay missed events:
Replay Events
From: Nov 20, 2025 14:00
To: Nov 25, 2025 16:00
Event Types: [email.opened, email.clicked]
Found: 1,247 matching events
[Replay All Events]
Status: Replaying... (234 / 1,247)
Use Cases:
-
Recover from downtime
-
Backfill data after webhook creation
-
Re-sync after bug fixes
Level 3: Technical Implementation
Database Schema
-- Webhook endpoints
CREATE TABLE webhooks (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
-- Configuration
name VARCHAR(255) NOT NULL,
description TEXT,
endpoint_url TEXT NOT NULL,
secret_key VARCHAR(255) NOT NULL, -- For signature generation
-- Event filtering
event_types TEXT[], -- e.g., ['email.opened', 'email.clicked']
event_filters JSONB, -- Additional filtering criteria
-- Status
is_active BOOLEAN DEFAULT TRUE,
is_failing BOOLEAN DEFAULT FALSE,
consecutive_failures INTEGER DEFAULT 0,
last_success_at TIMESTAMP,
last_failure_at TIMESTAMP,
-- Metadata
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Webhook delivery attempts
CREATE TABLE webhook_deliveries (
id UUID PRIMARY KEY,
webhook_id UUID NOT NULL REFERENCES webhooks(id),
event_id UUID NOT NULL,
-- Request
event_type VARCHAR(100),
payload JSONB,
signature VARCHAR(255),
-- Response
status_code INTEGER,
response_body TEXT,
response_headers JSONB,
-- Timing
duration_ms INTEGER,
attempted_at TIMESTAMP DEFAULT NOW(),
-- Retry tracking
attempt_number INTEGER DEFAULT 1,
next_retry_at TIMESTAMP,
-- Status
status VARCHAR(50), -- success, failed, pending_retry
error_message TEXT
);
CREATE INDEX idx_webhook_deliveries_webhook ON webhook_deliveries(webhook_id, attempted_at);
CREATE INDEX idx_webhook_deliveries_status ON webhook_deliveries(status, next_retry_at);
-- Events log (for replay)
CREATE TABLE webhook_events (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
event_type VARCHAR(100),
event_data JSONB,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_webhook_events_tenant_type ON webhook_events(tenant_id, event_type, created_at);
Webhook Delivery Service
class WebhookDeliveryService {
async deliverEvent(event: WebhookEvent): Promise<void> {
// Find all webhooks subscribed to this event type
const webhooks = await this.findMatchingWebhooks(event);
for (const webhook of webhooks) {
await this.queueDelivery(webhook, event);
}
}
private async findMatchingWebhooks(event: WebhookEvent): Promise<Webhook[]> {
return db.webhooks.findAll({
where: {
tenantId: event.tenantId,
isActive: true,
eventTypes: { contains: [event.type] },
},
});
}
private async queueDelivery(webhook: Webhook, event: WebhookEvent): Promise<void> {
await webhookQueue.add('deliver-webhook', {
webhookId: webhook.id,
eventId: event.id,
payload: event.data,
}, {
attempts: 5,
backoff: {
type: 'exponential',
delay: 60000, // Start with 1 minute
},
});
}
async deliver(webhookId: string, event: WebhookEvent): Promise<void> {
const webhook = await db.webhooks.findById(webhookId);
// Generate signature
const payload = JSON.stringify(event);
const signature = this.generateSignature(payload, webhook.secretKey);
// Make HTTP request
const startTime = Date.now();
try {
const response = await axios.post(webhook.endpointUrl, payload, {
headers: {
'Content-Type': 'application/json',
'X-PenguinMails-Signature': signature,
'X-PenguinMails-Event': event.type,
'X-PenguinMails-Delivery-ID': uuidv4(),
},
timeout: 5000, // 5 second timeout
});
const duration = Date.now() - startTime;
// Log successful delivery
await db.webhookDeliveries.create({
webhookId,
eventId: event.id,
eventType: event.type,
payload: event.data,
signature,
statusCode: response.status,
responseBody: response.data,
responseHeaders: response.headers,
durationMs: duration,
status: 'success',
});
// Reset failure tracking
await db.webhooks.update(webhookId, {
consecutiveFailures: 0,
isFailing: false,
lastSuccessAt: new Date(),
});
} catch (error) {
await this.handleDeliveryFailure(webhook, event, error);
}
}
private generateSignature(payload: string, secret: string): string {
const hmac = crypto.createHmac('sha256', secret);
hmac.update(payload);
return `sha256=${hmac.digest('hex')}`;
}
private async handleDeliveryFailure(
webhook: Webhook,
event: WebhookEvent,
error: any
): Promise<void> {
const failures = webhook.consecutiveFailures + 1;
await db.webhookDeliveries.create({
webhookId: webhook.id,
eventId: event.id,
status: 'failed',
errorMessage: error.message,
});
await db.webhooks.update(webhook.id, {
consecutiveFailures: failures,
isFailing: failures >= 3,
lastFailureAt: new Date(),
});
// Pause webhook after 100 failures
if (failures >= 100) {
await db.webhooks.update(webhook.id, {
isActive: false,
});
await notificationService.send({
tenantId: webhook.tenantId,
type: 'webhook_paused',
data: { webhookId: webhook.id, failures },
});
}
}
}
Event Emission
// Emit webhook event when email is opened
async function handleEmailOpen(emailId: string): Promise<void> {
const email = await db.emails.findById(emailId);
const contact = await db.contacts.findById(email.contactId);
const campaign = await db.campaigns.findById(email.campaignId);
const event: WebhookEvent = {
id: uuidv4(),
type: 'email.opened',
tenantId: campaign.tenantId,
createdAt: new Date(),
data: {
emailId: email.id,
campaignId: campaign.id,
contact: {
id: contact.id,
email: contact.email,
firstName: contact.firstName,
lastName: contact.lastName,
},
openedAt: new Date(),
userAgent: req.headers['user-agent'],
ipAddress: req.ip,
},
};
// Store event for replay
await db.webhookEvents.create(event);
// Deliver to webhooks
await webhookDeliveryService.deliverEvent(event);
}
API Endpoints
// Create webhook
app.post('/api/webhooks', authenticate, async (req, res) => {
const { name, endpointUrl, eventTypes, eventFilters } = req.body;
// Generate secret key
const secretKey = `whsec_${randomBytes(32).toString('hex')}`;
const webhook = await db.webhooks.create({
tenantId: req.user.tenantId,
name,
endpointUrl,
secretKey,
eventTypes,
eventFilters,
isActive: true,
});
return res.json({
id: webhook.id,
name: webhook.name,
endpointUrl: webhook.endpointUrl,
secretKey, // Only shown once
eventTypes: webhook.eventTypes,
});
});
// Test webhook
app.post('/api/webhooks/:id/test', authenticate, async (req, res) => {
const webhook = await db.webhooks.findById(req.params.id);
const testEvent = {
id: `evt_test_${Date.now()}`,
type: 'email.opened',
created_at: new Date().toISOString(),
data: {
// Sample data
email_id: 'msg_sample',
contact: {
email: 'test@example.com',
first_name: 'Test',
},
},
};
try {
await webhookDeliveryService.deliver(webhook.id, testEvent);
return res.json({ success: true });
} catch (error) {
return res.status(400).json({ error: error.message });
}
});
Event Reference
Email Events
| Event Type | Description | Payload |
|---|---|---|
email.sent | Email queued for sending | emailId, campaignId, contact |
email.delivered | Email successfully delivered | emailId, deliveredAt, smtpResponse |
email.opened | Email opened by recipient | emailId, openedAt, location, userAgent |
email.clicked | Link clicked in email | emailId, url, clickedAt |
email.bounced | Email bounced | emailId, bounceType, bounceReason |
email.spam_reported | Marked as spam | emailId, reportedAt |
email.unsubscribed | Recipient unsubscribed | emailId, contactId, unsubscribedAt |
Campaign Events
| Event Type | Description |
|---|---|
campaign.launched | Campaign started sending |
campaign.completed | Campaign finished sending |
campaign.paused | Campaign paused by user |
Contact Events
| Event Type | Description |
|---|---|
contact.created | New contact added |
contact.updated | Contact information updated |
contact.unsubscribed | Contact unsubscribed from all emails |
Related Documentation
Route Specifications
-
Webhook System Routes - Complete webhook UI routes
-
API Access Routes - API key management routes
-
ESP Integration Routes - ESP webhook configuration
Feature Documentation
-
API Access - REST API and authentication
-
Vault API Keys - Secure API key system
-
ESP Integration - External ESP webhooks
-
CRM Integration - Pre-built integrations
API Documentation
-
Platform API - Webhook management endpoints
-
Tenant API - Event data endpoints
-
Queue System - Event processing
Architecture & Implementation
-
Integrations Review - Integration completeness review
-
Email Pipeline - Email sending infrastructure
-
Campaign Management - Campaign events
-
Epic 6: Core Email Pipeline - Internal task reference for email pipeline work
User Journeys
-
Technical Teams Journeys - Developer workflows (internal journey reference)
-
Developer Onboarding - Developer setup (internal journey reference)
Last Updated: November 25, 2025 Status: Planned - MVP Feature (Level 2) Target Release: Q1 2026 Owner: Integrations Team