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:
| Event | Description |
|---|---|
| email.sent | Email was accepted by the mail server |
| email.delivered | Email was delivered to recipient inbox |
| email.opened | Recipient opened the email |
| email.clicked | Recipient clicked a link in the email |
| email.bounced | Email bounced (hard or soft bounce) |
| email.complained | Recipient marked email as spam |
| email.delivery_delayed | Email delivery was delayed |
| contact.created | A new contact was added to an audience |
| contact.updated | A contact was updated |
| contact.deleted | A contact was deleted |
| audience.created | A new audience was created |
| audience.updated | An audience was updated |
| audience.deleted | An audience was deleted |
Webhook Headers
Every webhook request includes these headers:
| Header | Description |
|---|---|
| X-Webhook-Signature | HMAC-SHA256 signature in format: t=timestamp,v1=signature |
| X-Webhook-Id | Your webhook ID for reference |
| X-Webhook-Timestamp | Unix 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.
| Attempt | Action |
|---|---|
| 1-9 | Retried on failure |
| 10+ | Webhook automatically disabled |