Testing Webhooks
How to test webhook delivery and verify HMAC signatures
Webhook Requirements
All webhook endpoints must:
- Use HTTPS - Plain HTTP endpoints are rejected
- Return HTTP 200 within 5 seconds to acknowledge receipt
- Accept POST requests with JSON body
- Verify HMAC signatures to prevent spoofing
Event Payload Structure
All webhook events follow this structure:
{
"event": "message.received",
"webhook_id": "wh_xyz789",
"inbox_id": "inb_abc123",
"timestamp": "2024-03-11T15:30:00Z",
"data": {
// Event-specific payload
}
}message.received Event
{
"event": "message.received",
"webhook_id": "wh_xyz789",
"inbox_id": "inb_abc123",
"timestamp": "2024-03-11T15:30:00Z",
"data": {
"message": {
"id": "msg_abc123",
"from": "sender@example.com",
"to": "my-agent@daimon.email",
"subject": "Test Email",
"body_text": "Hello from the webhook!",
"body_html": "<p>Hello from the webhook!</p>",
"reply_body": "Hello from the webhook!",
"links": ["https://example.com/verify?token=xyz"],
"cta_links": ["https://example.com/verify?token=xyz"],
"received_at": "2024-03-11T15:30:00Z"
}
}
}HMAC Signature Verification
Every webhook delivery includes an X-Daimon-Signature header containing an HMAC-SHA256 signature. Always verify this signature to prevent spoofed requests.
import crypto from 'crypto';
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
const hmac = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(hmac)
);
}
// Express.js example
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-daimon-signature'] as string;
const payload = req.body.toString();
if (!verifyWebhookSignature(payload, signature, webhookSecret)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(payload);
if (event.event === 'message.received') {
const message = event.data.message;
console.log(`New message: ${message.subject}`);
// Process the message
}
res.status(200).send('OK');
});import hmac
import hashlib
def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
computed = hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, computed)
# Flask example
@app.route('/webhook', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Daimon-Signature')
payload = request.get_data()
if not verify_webhook_signature(payload, signature, webhook_secret):
return 'Invalid signature', 401
event = request.get_json()
if event['event'] == 'message.received':
message = event['data']['message']
print(f"New message: {message['subject']}")
# Process the message
return 'OK', 200from fastapi import FastAPI, Request, Response, HTTPException
import hmac
import hashlib
app = FastAPI()
def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
computed = hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, computed)
@app.post('/webhook')
async def handle_webhook(request: Request):
signature = request.headers.get('x-daimon-signature')
payload = await request.body()
if not verify_webhook_signature(payload, signature, webhook_secret):
raise HTTPException(status_code=401, detail='Invalid signature')
event = await request.json()
if event['event'] == 'message.received':
message = event['data']['message']
print(f"New message: {message['subject']}")
# Process the message
return Response(status_code=200, content='OK')Testing with ngrok
For local development, use ngrok to expose your webhook endpoint:
# Start ngrok tunnel
ngrok http 3000
# Use the HTTPS URL in your webhook
curl -X POST https://api.daimon.email/v1/webhooks \
-H "Authorization: Bearer dm_free_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://abc123.ngrok.io/webhook",
"events": ["message.received"]
}'Webhook Health & Failure Handling
Webhooks are marked unhealthy after 5 consecutive failures:
- Timeout (no response within 5 seconds)
- Non-2xx HTTP status code
- Connection errors (DNS, TLS, etc.)
After 10 consecutive failures, webhooks are automatically disabled.
Monitoring Webhook Health
const webhook = await client.webhooks.get('wh_xyz789');
if (webhook.status === 'unhealthy') {
console.warn(`Webhook unhealthy: ${webhook.failure_count} failures`);
console.log(`Last success: ${webhook.last_delivered_at}`);
// Re-enable after fixing endpoint
await client.webhooks.update('wh_xyz789', { status: 'active' });
}Retry Behavior
- Failed deliveries are retried with exponential backoff
- Retry intervals: 1s, 5s, 30s, 2m, 10m
- After 5 failures, webhook is marked
unhealthybut retries continue - After 10 failures, webhook is disabled and retries stop
Best Practices
Return 200 immediately
Process webhooks asynchronously. Respond with HTTP 200 within 5 seconds, then handle the event in a background job.
app.post('/webhook', async (req, res) => {
// Verify signature
if (!verifySignature(req.body, req.headers['x-daimon-signature'])) {
return res.status(401).send('Invalid signature');
}
// Acknowledge immediately
res.status(200).send('OK');
// Process asynchronously
await queue.add('process-webhook', req.body);
});Implement idempotency
Webhooks may be delivered more than once. Use the message.id to deduplicate events.
const messageId = event.data.message.id;
if (await redis.exists(`processed:${messageId}`)) {
return; // Already processed
}
await processMessage(event.data.message);
await redis.setex(`processed:${messageId}`, 86400, '1'); // 24hr TTLAlways verify signatures
Never process unverified webhooks. Attackers can spoof webhook events.
if (!verifyWebhookSignature(payload, signature, secret)) {
return res.status(401).send('Invalid signature');
}Monitor webhook health
Set up alerts for unhealthy webhooks. Check the /v1/webhooks endpoint regularly.
const webhooks = await client.webhooks.list({ status: 'unhealthy' });
if (webhooks.length > 0) {
await alertOps(`${webhooks.length} unhealthy webhooks`);
}Testing Signature Verification
You can test signature verification with this example payload:
# Secret from webhook creation
SECRET="whsec_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
# Example payload
PAYLOAD='{"event":"message.received","webhook_id":"wh_xyz789"}'
# Generate signature
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)
echo "X-Daimon-Signature: $SIGNATURE"
# Test your endpoint
curl -X POST https://your-endpoint.com/webhook \
-H "Content-Type: application/json" \
-H "X-Daimon-Signature: $SIGNATURE" \
-d "$PAYLOAD"Info
Debugging tip: Log the raw request body and computed signature. Common issues include: body parsing (use raw body), character encoding, and timing attacks (use constant-time comparison).