Webhook Handler
Handle Owlat delivery webhooks with signature verification and event routing.
Handle Owlat delivery webhooks with signature verification and event routing.
Prerequisites
- A webhook configured in Settings → Webhooks with a
whsec_...secret - A publicly accessible endpoint that accepts
POSTrequests
Complete Express handler
import express from 'express'
import crypto from 'crypto'
const app = express()
// Use raw body for signature verification
app.post('/webhooks/owlat', express.raw({ type: 'application/json' }), async (req, res) => {
const payload = req.body.toString()
const signature = req.headers['x-signature'] as string
const timestamp = req.headers['x-timestamp'] as string
// 1. Verify signature
if (!verifySignature(payload, signature, process.env.OWLAT_WEBHOOK_SECRET!)) {
return res.status(401).json({ error: 'Invalid signature' })
}
// 2. Check timestamp freshness (reject requests older than 5 minutes)
const age = Math.abs(Date.now() / 1000 - Number(timestamp))
if (age > 300) {
return res.status(401).json({ error: 'Request too old' })
}
// 3. Return 200 immediately — process async
res.status(200).json({ received: true })
// 4. Route events
const event = JSON.parse(payload)
await handleEvent(event)
})
function verifySignature(payload: string, signature: string, secret: string): boolean {
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
)
}
Owlat expects a response within 30 seconds. Return 200 as soon as you've verified the signature, then process the event asynchronously. This prevents retries for slow handlers.
Event routing
Route events by type using a switch/case:
interface WebhookEvent {
event: string
timestamp: string
data: Record<string, unknown>
}
async function handleEvent(event: WebhookEvent) {
switch (event.event) {
case 'email.delivered':
console.log(`Delivered to ${event.data.email}`)
await db.emails.update({
where: { messageId: event.data.messageId as string },
data: { status: 'delivered', deliveredAt: event.timestamp },
})
break
case 'email.bounced':
console.log(`Bounced: ${event.data.email}`)
// Mark the contact as undeliverable in your system
await db.users.update({
where: { email: event.data.email as string },
data: { emailStatus: 'bounced' },
})
break
case 'email.opened':
console.log(`Opened by ${event.data.email}`)
await db.emails.update({
where: { messageId: event.data.messageId as string },
data: { openedAt: event.timestamp },
})
break
case 'email.clicked':
console.log(`Click from ${event.data.email}: ${event.data.url}`)
await db.clickEvents.create({
data: {
messageId: event.data.messageId as string,
url: event.data.url as string,
clickedAt: event.timestamp,
},
})
break
case 'email.complained':
console.log(`Complaint from ${event.data.email}`)
await db.users.update({
where: { email: event.data.email as string },
data: { emailStatus: 'complained', suppressEmail: true },
})
break
default:
console.log(`Unhandled event: ${event.event}`)
}
}
Bounce handling
Bounces are the most important event to handle. Hard bounces mean the address is permanently invalid — continuing to send to it hurts your sender reputation:
case 'email.bounced':
const { email, bounceType } = event.data as {
email: string
bounceType: 'hard' | 'soft'
}
if (bounceType === 'hard') {
// Permanently suppress this address
await db.users.update({
where: { email },
data: { emailStatus: 'hard_bounced', suppressEmail: true },
})
} else {
// Soft bounce — log it, but don't suppress yet
await db.bounceLog.create({
data: { email, type: 'soft', timestamp: event.timestamp },
})
}
break
Owlat automatically adds hard-bounced addresses to your blocklist. The webhook lets you mirror this state in your own database so you can avoid triggering sends to those addresses in the first place.
Webhook payload shape
Every webhook delivery follows this structure:
{
"event": "email.delivered",
"timestamp": "2026-03-19T12:00:00.000Z",
"data": {
"messageId": "msg_abc123",
"email": "mira@acme.io",
"transactionalSlug": "welcome"
}
}
Headers included with every delivery:
| Header | Description |
|---|---|
X-Signature | HMAC-SHA256 hex digest of the payload |
X-Timestamp | Unix timestamp (seconds) |
X-Webhook-Id | The webhook configuration ID |
Next steps
- Webhooks API reference — full event list and payload documentation
- Deliverability guide — monitor inbox placement and reputation
- Billing Email — track delivery of receipts