Skip to content

Webhooks Example

Learn how to handle real-time email notifications with webhooks instead of polling.

Why Webhooks?

Webhooks provide instant notifications when emails arrive, making your AI agents more responsive:

Polling (❌ Slow & Inefficient):

  • Check for new emails every 30-60 seconds
  • Wastes API quota on empty requests
  • Delayed responses (up to 60s latency)
  • Difficult to scale

Webhooks (✅ Fast & Efficient):

  • Get notified instantly when emails arrive
  • No wasted API calls
  • Real-time responses (< 1s latency)
  • Scales effortlessly

Prerequisites

  • Myxara API key
  • A server to receive webhooks
  • For local development: ngrok or similar tunneling tool

Basic Webhook Handler

Express.js

typescript
import express from 'express'
import crypto from 'crypto'
import { MyxaraClient } from '@myxara/sdk-js'

const app = express()
app.use(express.json())

const client = new MyxaraClient({
  apiKey: process.env.MYXARA_API_KEY!
})

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!

// Verify webhook signature
function verifySignature(signature: string, payload: any): boolean {
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(JSON.stringify(payload))
    .digest('hex')

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  )
}

// Webhook endpoint
app.post('/webhook', async (req, res) => {
  // 1. Verify signature
  const signature = req.headers['myxara-signature'] as string

  if (!verifySignature(signature, req.body)) {
    console.error('❌ Invalid webhook signature')
    return res.status(401).send('Invalid signature')
  }

  // 2. Parse event
  const event = req.body

  console.log(`✅ Received event: ${event.type}`)

  // 3. Handle different event types
  switch (event.type) {
    case 'message.received':
      await handleMessageReceived(event.data.message)
      break

    case 'message.sent':
      console.log(`Message sent: ${event.data.message.id}`)
      break

    case 'message.failed':
      console.error(`Message failed: ${event.data.message.id}`)
      await handleMessageFailed(event.data.message)
      break

    case 'message.bounced':
      console.warn(`Message bounced: ${event.data.message.id}`)
      await handleMessageBounced(event.data.message)
      break
  }

  // 4. Always respond quickly (< 5 seconds)
  res.sendStatus(200)
})

async function handleMessageReceived(message: any) {
  console.log(`New message from ${message.from}`)
  console.log(`Subject: ${message.subject}`)

  // Auto-reply
  await client.inboxes.messages(message.inbox_id).send({
    to: message.from,
    subject: `Re: ${message.subject}`,
    text: 'Thanks for your message. We will respond shortly.',
    in_reply_to: message.id
  })
}

async function handleMessageFailed(message: any) {
  // Alert admin
  console.error('Message delivery failed:', message)
}

async function handleMessageBounced(message: any) {
  // Mark email as invalid, remove from list
  console.warn('Email bounced:', message.to)
}

app.listen(3000, () => {
  console.log('🚀 Webhook server running on port 3000')
})

Next.js API Route

typescript
// app/api/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'
import { MyxaraClient } from '@myxara/sdk-js'

const client = new MyxaraClient({
  apiKey: process.env.MYXARA_API_KEY!
})

function verifySignature(signature: string, payload: any): boolean {
  const expected = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET!)
    .update(JSON.stringify(payload))
    .digest('hex')

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  )
}

export async function POST(req: NextRequest) {
  const signature = req.headers.get('myxara-signature')

  if (!signature) {
    return NextResponse.json(
      { error: 'Missing signature' },
      { status: 401 }
    )
  }

  const event = await req.json()

  if (!verifySignature(signature, event)) {
    return NextResponse.json(
      { error: 'Invalid signature' },
      { status: 401 }
    )
  }

  // Handle event
  if (event.type === 'message.received') {
    const { message } = event.data

    // Process message...
    await processMessage(message)
  }

  return NextResponse.json({ received: true })
}

async function processMessage(message: any) {
  // Your logic here
  console.log(`Processing ${message.id}`)
}

Setup Webhook

Create a webhook to receive notifications:

typescript
import { MyxaraClient } from '@myxara/sdk-js'

const client = new MyxaraClient({
  apiKey: process.env.MYXARA_API_KEY!
})

// Create webhook
const webhook = await client.webhooks.create({
  url: 'https://your-server.com/webhook',
  events: ['message.received', 'message.failed'],
  description: 'Production webhook'
})

console.log('Webhook created:', webhook.id)
console.log('Secret:', webhook.secret)
console.log('\n⚠️ Save this secret to your environment:')
console.log(`WEBHOOK_SECRET=${webhook.secret}`)

Save the Secret

The webhook secret is only shown once. Save it to your environment variables immediately!

Event Types

message.received

Triggered when a new email arrives:

json
{
  "id": "evt_abc123",
  "type": "message.received",
  "created_at": "2025-01-15T10:30:00Z",
  "data": {
    "message": {
      "id": "msg_xyz789",
      "inbox_id": "inbox_abc123",
      "from": "[email protected]",
      "to": "[email protected]",
      "subject": "Need help",
      "text": "I need assistance with...",
      "html": "<p>I need assistance with...</p>",
      "direction": "in",
      "created_at": "2025-01-15T10:30:00Z"
    }
  }
}

message.sent

Triggered when an email is successfully sent:

json
{
  "id": "evt_def456",
  "type": "message.sent",
  "created_at": "2025-01-15T10:31:00Z",
  "data": {
    "message": {
      "id": "msg_sent123",
      "inbox_id": "inbox_abc123",
      "from": "[email protected]",
      "to": "[email protected]",
      "subject": "Re: Need help",
      "status": "sent"
    }
  }
}

message.failed

Triggered when email delivery fails:

json
{
  "id": "evt_ghi789",
  "type": "message.failed",
  "created_at": "2025-01-15T10:32:00Z",
  "data": {
    "message": {
      "id": "msg_failed123",
      "status": "failed",
      "error": "Recipient address rejected"
    }
  }
}

Background Processing

Always respond to webhooks within 5 seconds. For long tasks, use background processing:

Using Bull Queue

typescript
import Queue from 'bull'
import express from 'express'

const app = express()
app.use(express.json())

// Create queue
const messageQueue = new Queue('messages', {
  redis: {
    host: 'localhost',
    port: 6379
  }
})

// Webhook handler (fast response)
app.post('/webhook', async (req, res) => {
  const signature = req.headers['myxara-signature'] as string

  if (!verifySignature(signature, req.body)) {
    return res.status(401).send('Invalid signature')
  }

  const event = req.body

  // Queue for background processing
  if (event.type === 'message.received') {
    await messageQueue.add('process-message', event.data.message, {
      attempts: 3,
      backoff: {
        type: 'exponential',
        delay: 2000
      }
    })
  }

  // Respond immediately
  res.sendStatus(200)
})

// Background worker
messageQueue.process('process-message', async (job) => {
  const message = job.data

  console.log(`Processing message ${message.id}`)

  // Long-running task here...
  await processWithAI(message)

  console.log(`Completed processing ${message.id}`)
})

app.listen(3000)

Using Worker Threads

typescript
import { Worker } from 'worker_threads'

app.post('/webhook', async (req, res) => {
  // Verify signature...

  const event = req.body

  if (event.type === 'message.received') {
    // Process in worker thread
    const worker = new Worker('./message-worker.js', {
      workerData: event.data.message
    })

    worker.on('message', (result) => {
      console.log('Worker completed:', result)
    })

    worker.on('error', (error) => {
      console.error('Worker error:', error)
    })
  }

  // Respond immediately
  res.sendStatus(200)
})

Testing Locally

Using ngrok

bash
# Install ngrok
npm install -g ngrok

# Start your server
bun run server.ts

# In another terminal, expose to internet
ngrok http 3000

Copy the ngrok URL and create a webhook:

typescript
const webhook = await client.webhooks.create({
  url: 'https://abc123.ngrok.io/webhook',
  events: ['message.received']
})

Send Test Event

Send an email to trigger the webhook:

typescript
await client.inboxes.messages(inboxId).send({
  to: '[email protected]',
  subject: 'Test webhook',
  text: 'This should trigger a webhook event'
})

Security Best Practices

1. Always Verify Signatures

typescript
// ✅ Good - verifies signature
if (!verifySignature(signature, req.body)) {
  return res.status(401).send('Invalid signature')
}

// ❌ Bad - no verification
app.post('/webhook', async (req, res) => {
  const event = req.body
  // Process without verifying - DANGEROUS!
})

2. Use HTTPS Only

typescript
// ✅ Good - HTTPS
const webhook = await client.webhooks.create({
  url: 'https://your-server.com/webhook',
  events: ['message.received']
})

// ❌ Bad - HTTP (insecure)
const webhook = await client.webhooks.create({
  url: 'http://your-server.com/webhook',  // Won't work
  events: ['message.received']
})

3. Keep Secrets Safe

bash
# .env
WEBHOOK_SECRET=whsec_abc123def456

# .gitignore
.env
.env.local

4. Rate Limiting

typescript
import rateLimit from 'express-rate-limit'

const limiter = rateLimit({
  windowMs: 1 * 60 * 1000,  // 1 minute
  max: 100  // Max 100 requests per minute
})

app.use('/webhook', limiter)

Error Handling

Handle webhook processing errors gracefully:

typescript
app.post('/webhook', async (req, res) => {
  try {
    const signature = req.headers['myxara-signature'] as string

    if (!verifySignature(signature, req.body)) {
      return res.status(401).send('Invalid signature')
    }

    const event = req.body

    if (event.type === 'message.received') {
      await handleMessageReceived(event.data.message)
    }

    res.sendStatus(200)

  } catch (error) {
    console.error('Webhook processing error:', error)

    // Still return 200 to prevent retries for unrecoverable errors
    // Myxara will retry 5xx errors
    res.sendStatus(200)
  }
})

async function handleMessageReceived(message: any) {
  try {
    // Process message
    await processMessage(message)

  } catch (error) {
    console.error(`Failed to process message ${message.id}:`, error)

    // Log to error tracking service
    logError(error, { message_id: message.id })

    // Send fallback response
    await client.inboxes.messages(message.inbox_id).send({
      to: message.from,
      subject: `Re: ${message.subject}`,
      text: 'We received your message but encountered an issue. Our team will respond shortly.',
      metadata: { error: true }
    })
  }
}

Monitoring

Track webhook health and performance:

typescript
interface WebhookMetrics {
  total_events: number
  successful: number
  failed: number
  avg_processing_time: number
}

const metrics: WebhookMetrics = {
  total_events: 0,
  successful: 0,
  failed: 0,
  avg_processing_time: 0
}

app.post('/webhook', async (req, res) => {
  const startTime = Date.now()

  try {
    // Verify signature...
    // Process event...

    metrics.successful++
    res.sendStatus(200)

  } catch (error) {
    metrics.failed++
    res.sendStatus(500)

  } finally {
    metrics.total_events++
    const processingTime = Date.now() - startTime

    // Update average processing time
    metrics.avg_processing_time =
      (metrics.avg_processing_time * (metrics.total_events - 1) + processingTime) /
      metrics.total_events
  }
})

// Metrics endpoint
app.get('/metrics', (req, res) => {
  res.json({
    ...metrics,
    success_rate: ((metrics.successful / metrics.total_events) * 100).toFixed(2) + '%',
    uptime: process.uptime()
  })
})

Retry Behavior

Myxara automatically retries failed webhooks:

  • 1st retry: 1 minute after failure
  • 2nd retry: 5 minutes after failure
  • 3rd retry: 30 minutes after failure
  • 4th retry: 2 hours after failure
  • 5th retry: 12 hours after failure

After 5 failed attempts, the webhook is disabled.

Idempotency

Make your webhook handler idempotent - it should safely handle the same event multiple times without side effects.

typescript
const processedEvents = new Set<string>()

app.post('/webhook', async (req, res) => {
  const event = req.body

  // Check if already processed
  if (processedEvents.has(event.id)) {
    console.log(`Event ${event.id} already processed - skipping`)
    return res.sendStatus(200)
  }

  // Process event
  await handleEvent(event)

  // Mark as processed
  processedEvents.add(event.id)

  res.sendStatus(200)
})

Complete Production Example

typescript
import express from 'express'
import crypto from 'crypto'
import Queue from 'bull'
import { MyxaraClient } from '@myxara/sdk-js'

const app = express()
app.use(express.json())

const client = new MyxaraClient({
  apiKey: process.env.MYXARA_API_KEY!
})

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!

// Queue for background processing
const queue = new Queue('webhooks', process.env.REDIS_URL!)

// Processed events cache (use Redis in production)
const processedEvents = new Set<string>()

function verifySignature(signature: string, payload: any): boolean {
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(JSON.stringify(payload))
    .digest('hex')

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  )
}

app.post('/webhook', async (req, res) => {
  const startTime = Date.now()

  try {
    // 1. Verify signature
    const signature = req.headers['myxara-signature'] as string

    if (!signature || !verifySignature(signature, req.body)) {
      console.error('Invalid signature')
      return res.status(401).send('Invalid signature')
    }

    const event = req.body

    // 2. Check if already processed (idempotency)
    if (processedEvents.has(event.id)) {
      console.log(`Event ${event.id} already processed`)
      return res.sendStatus(200)
    }

    // 3. Queue for background processing
    await queue.add('process-event', event, {
      attempts: 3,
      backoff: { type: 'exponential', delay: 2000 }
    })

    // 4. Mark as processed
    processedEvents.add(event.id)

    // 5. Log metrics
    const duration = Date.now() - startTime
    console.log(`Event ${event.id} queued in ${duration}ms`)

    // 6. Respond quickly
    res.sendStatus(200)

  } catch (error) {
    console.error('Webhook error:', error)
    res.sendStatus(500)
  }
})

// Background worker
queue.process('process-event', async (job) => {
  const event = job.data

  console.log(`Processing event ${event.type}`)

  if (event.type === 'message.received') {
    const { message } = event.data

    // Your processing logic here
    await processMessage(message)
  }
})

async function processMessage(message: any) {
  // Auto-reply logic
  console.log(`Processing message from ${message.from}`)

  await client.inboxes.messages(message.inbox_id).send({
    to: message.from,
    subject: `Re: ${message.subject}`,
    text: 'Thanks for your message!',
    in_reply_to: message.id
  })
}

app.listen(3000, () => {
  console.log('🚀 Production webhook server running')
})

Next Steps

Released under the MIT License (SDK) & Elastic License 2.0 (Server)