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')
  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)
}
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}`)
      // 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}`)
  }
}
Correlate by email, not message ID

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
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.

Events Owlat fires

Owlat sends these eight outbound events. Subscribe to the ones you care about per webhook:

Eventdata fields
email.sentemail, campaignId (or null), transactionalEmailId (or null), timestamp
email.deliveredemail, timestamp
email.openedemail, timestamp
email.clickedemail, url, timestamp
email.bouncedemail, bounceType (hard/soft), message, timestamp
email.complainedemail, timestamp
contact.createdcontactId, email, source, timestamp
topic.unsubscribedcontactId, email, unsubscribedAt, listsRemoved
topic.unsubscribed.listsRemoved is a JSON string

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:

HeaderDescription
X-SignatureHMAC-SHA256 hex digest of the payload body
X-TimestampUnix timestamp (seconds) the request was sent
X-Webhook-IdThe webhook configuration ID
Content-Typeapplication/json
User-AgentOwlat-Webhooks/1.0

Next steps