Webhooks API

Set up webhooks to receive real-time notifications about email events, form submissions, and other actions in your Metigan account.

Create Webhook

create-webhook.tsTypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import Metigan from 'metigan';

const metigan = new Metigan({
  apiKey: 'your_api_key'
});

// Create a webhook
const webhook = await metigan.webhooks.create({
  url: 'https://your-app.com/webhooks/metigan',
  events: [
    'email.sent',
    'email.delivered',
    'email.bounced',
    'email.opened',
    'email.clicked'
  ]
});

// Save the signature key securely - it will only be shown once!
console.log('Webhook created:', webhook.id);
console.log('Signature Key:', webhook.signatureKey);

Webhook Events

Available webhook events you can subscribe to:

EventDescription
email.sentEmail was accepted by the mail server
email.deliveredEmail was delivered to recipient inbox
email.openedRecipient opened the email
email.clickedRecipient clicked a link in the email
email.bouncedEmail bounced (hard or soft bounce)
email.complainedRecipient marked email as spam
email.delivery_delayedEmail delivery was delayed
contact.createdA new contact was added to an audience
contact.updatedA contact was updated
contact.deletedA contact was deleted
audience.createdA new audience was created
audience.updatedAn audience was updated
audience.deletedAn audience was deleted

Webhook Headers

Every webhook request includes these headers:

HeaderDescription
X-Webhook-SignatureHMAC-SHA256 signature in format: t=timestamp,v1=signature
X-Webhook-IdYour webhook ID for reference
X-Webhook-TimestampUnix timestamp when the webhook was sent

Webhook Payloads

Example webhook payload structures for different event types:

Email Events

email-delivered.jsonJSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
  "event": "email.delivered",
  "messageId": "msg_abc123xyz",
  "data": {
    "recipient": "user@example.com",
    "subject": "Welcome to our service!",
    "status": "delivered",
    "timestamp": "2024-01-15T10:30:00.000Z",
    "metadata": {
      "queueId": "1A86E41BB7",
      "dsn": "2.0.0",
      "statusDetail": "250 2.0.0 OK"
    }
  },
  "timestamp": 1705314600000
}

Email Opened

email-opened.jsonJSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
  "event": "email.opened",
  "messageId": "msg_abc123xyz",
  "data": {
    "recipient": "user@example.com",
    "subject": "Welcome to our service!",
    "status": "opened",
    "openCount": 1,
    "timestamp": "2024-01-15T10:35:00.000Z",
    "metadata": {
      "ip": "192.168.1.1",
      "userAgent": "Mozilla/5.0...",
      "geolocation": {
        "country": "US",
        "city": "New York"
      },
      "device": {
        "type": "desktop",
        "os": "Windows 10",
        "client": "Chrome 120"
      }
    }
  },
  "timestamp": 1705314900000
}

Contact Events

contact-created.jsonJSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
  "event": "contact.created",
  "messageId": "contact_def456xyz",
  "data": {
    "id": "contact_def456xyz",
    "email": "newuser@example.com",
    "firstName": "John",
    "lastName": "Doe",
    "status": "subscribed",
    "audienceId": "aud_123abc",
    "tags": ["newsletter", "premium"],
    "createdAt": "2024-01-15T10:30:00.000Z"
  },
  "timestamp": 1705314600000
}

Audience Events

audience-created.jsonJSON
1
2
3
4
5
6
7
8
9
10
11
{
  "event": "audience.created",
  "messageId": "aud_789ghi",
  "data": {
    "id": "aud_789ghi",
    "name": "Newsletter Subscribers",
    "description": "Users who signed up for the newsletter",
    "createdAt": "2024-01-15T10:30:00.000Z"
  },
  "timestamp": 1705314600000
}

Verify Webhook Signature

Important: Use Raw Body

You must use the raw request body (as a string) to verify the signature. Using a parsed JSON object will cause verification to fail.

Node.js / Express

verify-webhook-nodejs.tsTypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import express from 'express';
import crypto from 'crypto';

const app = express();

// IMPORTANT: Use express.raw() to get the body as a Buffer
app.post('/webhooks/metigan', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-webhook-signature'] as string;
  
  if (!signature) {
    return res.status(401).send('Missing signature');
  }
  
  // Convert Buffer to string for verification
  const payload = req.body.toString();
  
  if (!verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET!)) {
    return res.status(401).send('Invalid signature');
  }
  
  // Parse the verified payload
  const event = JSON.parse(payload);
  console.log('Webhook event:', event.event, event.data);
  
  res.status(200).send('OK');
});

function verifyWebhookSignature(
  payload: string,
  signature: string,
  secret: string,
  tolerance = 300 // 5 minutes
): boolean {
  try {
    // Parse signature: t=timestamp,v1=signature
    const parts = signature.split(',');
    const timestampPart = parts.find(p => p.startsWith('t='));
    const signaturePart = parts.find(p => p.startsWith('v1='));
    
    if (!timestampPart || !signaturePart) {
      return false;
    }
    
    const timestamp = parseInt(timestampPart.slice(2));
    const providedSignature = signaturePart.slice(3);
    
    // Check if signature is too old (replay attack protection)
    const now = Math.floor(Date.now() / 1000);
    if (now - timestamp > tolerance) {
      return false;
    }
    
    // Generate expected signature: HMAC-SHA256 of "timestamp.payload"
    const signedPayload = `${timestamp}.${payload}`;
    const expectedSignature = crypto
      .createHmac('sha256', secret)
      .update(signedPayload)
      .digest('hex');
    
    // Use timing-safe comparison to prevent timing attacks
    return crypto.timingSafeEqual(
      Buffer.from(providedSignature),
      Buffer.from(expectedSignature)
    );
  } catch {
    return false;
  }
}

app.listen(3000);

Python / Flask

verify-webhook-python.pyPython
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import hmac
import hashlib
import time
import os
from flask import Flask, request

app = Flask(__name__)

def verify_webhook_signature(payload: str, signature: str, secret: str, tolerance: int = 300) -> bool:
    try:
        # Parse signature: t=timestamp,v1=signature
        parts = signature.split(',')
        timestamp_part = next((p for p in parts if p.startswith('t=')), None)
        signature_part = next((p for p in parts if p.startswith('v1=')), None)
        
        if not timestamp_part or not signature_part:
            return False
        
        timestamp = int(timestamp_part[2:])
        provided_signature = signature_part[3:]
        
        # Check if signature is too old
        if time.time() - timestamp > tolerance:
            return False
        
        # Generate expected signature
        signed_payload = f"{timestamp}.{payload}"
        expected_signature = hmac.new(
            secret.encode(),
            signed_payload.encode(),
            hashlib.sha256
        ).hexdigest()
        
        # Use timing-safe comparison
        return hmac.compare_digest(provided_signature, expected_signature)
    except:
        return False

@app.route('/webhooks/metigan', methods=['POST'])
def webhook():
    signature = request.headers.get('X-Webhook-Signature')
    
    if not signature:
        return 'Missing signature', 401
    
    # Get raw payload as string
    payload = request.get_data(as_text=True)
    
    if not verify_webhook_signature(payload, signature, os.environ['WEBHOOK_SECRET']):
        return 'Invalid signature', 401
    
    event = request.get_json()
    print(f"Webhook event: {event['event']}")
    
    return {'received': True}

if __name__ == '__main__':
    app.run(port=3000)

PHP

verify-webhook-php.phpPhp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<?php

function verifyWebhookSignature(
    string $payload, 
    string $signature, 
    string $secret, 
    int $tolerance = 300
): bool {
    // Parse signature: t=timestamp,v1=signature
    $parts = explode(',', $signature);
    $timestamp = null;
    $providedSignature = null;
    
    foreach ($parts as $part) {
        if (strpos($part, 't=') === 0) {
            $timestamp = (int) substr($part, 2);
        }
        if (strpos($part, 'v1=') === 0) {
            $providedSignature = substr($part, 3);
        }
    }
    
    if (!$timestamp || !$providedSignature) {
        return false;
    }
    
    // Check if signature is too old
    if (time() - $timestamp > $tolerance) {
        return false;
    }
    
    // Generate expected signature
    $signedPayload = "{$timestamp}.{$payload}";
    $expectedSignature = hash_hmac('sha256', $signedPayload, $secret);
    
    // Use timing-safe comparison
    return hash_equals($expectedSignature, $providedSignature);
}

// Get the raw POST body
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

if (empty($signature)) {
    http_response_code(401);
    exit('Missing signature');
}

if (!verifyWebhookSignature($payload, $signature, getenv('WEBHOOK_SECRET'))) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = json_decode($payload, true);
echo "Webhook event: " . $event['event'];
http_response_code(200);
Security Best Practices
  • Always verify webhook signatures before processing events
  • Use HTTPS endpoints only for receiving webhooks
  • Implement timestamp tolerance to prevent replay attacks
  • Store your signature key securely (environment variables)
  • Respond quickly (within 30 seconds) to avoid timeouts
  • Return 2xx status codes for successful processing

Retry Policy

If your endpoint returns a non-2xx status code or times out, Metigan will automatically retry the webhook delivery. After 10 consecutive failures, the webhook will be automatically disabled to prevent further issues.

AttemptAction
1-9Retried on failure
10+Webhook automatically disabled