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')
const sigBuf = Buffer.from(signature, 'hex')
const expBuf = Buffer.from(expected, 'hex')
// timingSafeEqual throws on length mismatch — guard against malformed signatures
if (sigBuf.length !== expBuf.length) return false
return crypto.timingSafeEqual(sigBuf, expBuf)
}
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}`)
// Outbound payloads key only on the recipient email — correlate by
// email (and the event's own data.timestamp) rather than a message ID.
await db.users.update({
where: { email: event.data.email as string },
data: { lastDeliveredAt: event.data.timestamp as string },
})
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.users.update({
where: { email: event.data.email as string },
data: { lastOpenedAt: event.data.timestamp as string },
})
break
case 'email.clicked':
console.log(`Click from ${event.data.email}: ${event.data.url}`)
await db.clickEvents.create({
data: {
email: event.data.email as string,
url: event.data.url as string,
clickedAt: event.data.timestamp as string,
},
})
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}`)
}
}
Outbound payloads do not carry a message ID. The data map is a flat set of primitives keyed on the recipient email (plus url for clicks), and each event's data carries its own ISO-8601 timestamp. Correlate events to your own records by email address and timestamp.
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, message } = event.data as {
email: string
bounceType: 'hard' | 'soft'
message: string // provider-supplied reason, '' when none given
timestamp: string
}
if (bounceType === 'hard') {
// Permanently suppress this address
await db.users.update({
where: { email },
data: { emailStatus: 'hard_bounced', suppressEmail: true, bounceReason: message },
})
} else {
// Soft bounce — log it, but don't suppress yet
await db.bounceLog.create({
data: { email, type: 'soft', reason: message, timestamp: event.data.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.
Events Owlat fires
Owlat sends these eight outbound events. Subscribe to the ones you care about per webhook:
| Event | data fields |
|---|---|
email.sent | email, campaignId (or null), transactionalEmailId (or null), timestamp |
email.delivered | email, timestamp |
email.opened | email, timestamp |
email.clicked | email, url, timestamp |
email.bounced | email, bounceType (hard/soft), message, timestamp |
email.complained | email, timestamp |
contact.created | contactId, email, source, timestamp |
topic.unsubscribed | contactId, email, unsubscribedAt, listsRemoved |
The data map is a flat set of primitives, so listsRemoved is delivered as a JSON-encoded string, not an array. Receivers must JSON.parse(event.data.listsRemoved) to get the { topicId, topicName }[] list. unsubscribedAt is epoch milliseconds (a number), unlike the ISO-8601 timestamp on the email events.
Webhook payload shape
Every webhook delivery follows this envelope. Note that data carries its own ISO-8601 timestamp in addition to the envelope timestamp:
{
"event": "email.delivered",
"timestamp": "2026-03-19T12:00:00.000Z",
"data": {
"email": "mira@acme.io",
"timestamp": "2026-03-19T12:00:00.000Z"
}
}
Headers included with every delivery:
| Header | Description |
|---|---|
X-Signature | HMAC-SHA256 hex digest of the payload body |
X-Timestamp | Unix timestamp (seconds) the request was sent |
X-Webhook-Id | The webhook configuration ID |
Content-Type | application/json |
User-Agent | Owlat-Webhooks/1.0 |
Next steps
- Webhook Payloads — the authoritative per-event payload contract
- Webhooks API reference — configuration, signing, and retry behavior
- Deliverability guide — monitor inbox placement and reputation
- Billing Email — track delivery of receipts