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 POST requests

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)
  )
}
Return 200 quickly

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
Automatic suppression

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:

HeaderDescription
X-SignatureHMAC-SHA256 hex digest of the payload
X-TimestampUnix timestamp (seconds)
X-Webhook-IdThe webhook configuration ID

Next steps