Webhook-Handler

Verarbeiten Sie Owlats Zustell-Webhooks mit Signaturprüfung und Event-Routing.

Verarbeiten Sie Owlats Zustell-Webhooks mit Signaturprüfung und Event-Routing.

Voraussetzungen

  • Ein unter Einstellungen → Webhooks konfigurierter Webhook mit einem whsec_...-Secret
  • Ein öffentlich erreichbarer Endpunkt, der POST-Anfragen annimmt

Vollständiger 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)
}
Schnell 200 zurückgeben

Owlat erwartet eine Antwort innerhalb von 30 Sekunden. Geben Sie 200 zurück, sobald Sie die Signatur geprüft haben, und verarbeiten Sie das Event anschließend asynchron. Das verhindert Wiederholungsversuche bei langsamen Handlern.

Event-Routing

Routen Sie Events nach Typ über ein 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}`)
  }
}
Über die E-Mail-Adresse korrelieren, nicht über eine Message-ID

Ausgehende Payloads enthalten keine Message-ID. Die data-Map ist ein flacher Satz primitiver Werte, adressiert über die Empfängeradresse email (plus url bei Klicks), und die data jedes Events trägt ihren eigenen ISO-8601-timestamp. Ordnen Sie Events Ihren eigenen Datensätzen über E-Mail-Adresse und Zeitstempel zu.

Bounce-Behandlung

Bounces sind das wichtigste zu verarbeitende Event. Hard Bounces bedeuten, dass die Adresse dauerhaft ungültig ist — weiterhin dorthin zu senden schadet Ihrer Absenderreputation:

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
Automatische Unterdrückung

Owlat nimmt hart gebouncte Adressen automatisch in Ihre Blockliste auf. Der Webhook erlaubt es Ihnen, diesen Zustand in Ihrer eigenen Datenbank zu spiegeln, damit Sie Sendungen an diese Adressen gar nicht erst auslösen.

Events, die Owlat auslöst

Owlat sendet diese acht ausgehenden Events. Abonnieren Sie pro Webhook diejenigen, die für Sie relevant sind:

Eventdata-Felder
email.sentemail, campaignId (oder null), transactionalEmailId (oder 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 ist ein JSON-String

Die data-Map ist ein flacher Satz primitiver Werte, daher wird listsRemoved als JSON-kodierter String ausgeliefert, nicht als Array. Empfänger müssen JSON.parse(event.data.listsRemoved) aufrufen, um die Liste { topicId, topicName }[] zu erhalten. unsubscribedAt sind Epoch-Millisekunden (eine Zahl), anders als der ISO-8601-timestamp der E-Mail-Events.

Aufbau des Webhook-Payloads

Jede Webhook-Zustellung folgt diesem Umschlag. Beachten Sie, dass data zusätzlich zum timestamp des Umschlags einen eigenen ISO-8601-timestamp trägt:

{
  "event": "email.delivered",
  "timestamp": "2026-03-19T12:00:00.000Z",
  "data": {
    "email": "mira@acme.io",
    "timestamp": "2026-03-19T12:00:00.000Z"
  }
}

Bei jeder Zustellung enthaltene Header:

HeaderBeschreibung
X-SignatureHMAC-SHA256-Hex-Digest des Payload-Bodys
X-TimestampUnix-Zeitstempel (Sekunden) des Absendezeitpunkts der Anfrage
X-Webhook-IdDie ID der Webhook-Konfiguration
Content-Typeapplication/json
User-AgentOwlat-Webhooks/1.0

Nächste Schritte