Back to Novu

Webhooks and Events

.agents/skills/email-best-practices/resources/webhooks-events.md

3.15.04.2 KB
Original Source

Webhooks and Events

Receiving and processing email delivery events in real-time.

Event Types

EventWhen FiredUse For
email.sentEmail accepted by ResendConfirming send initiated
email.deliveredEmail delivered to recipient serverConfirming delivery
email.bouncedEmail bounced (hard or soft)List hygiene, alerting
email.complainedRecipient marked as spamImmediate unsubscribe
email.openedRecipient opened emailEngagement tracking
email.clickedRecipient clicked linkEngagement tracking

Webhook Setup

1. Create Endpoint

Your endpoint must:

  • Accept POST requests
  • Return 2xx status quickly (within 5 seconds)
  • Handle duplicate events (idempotent processing)
typescript
app.post('/webhooks/resend', async (req, res) => {
  // Return 200 immediately to acknowledge receipt
  res.status(200).send('OK');

  // Process asynchronously
  processWebhookAsync(req.body).catch(console.error);
});

2. Verify Signatures

Always verify webhook signatures to prevent spoofing.

typescript
import { Webhook } from 'svix';

const webhook = new Webhook(process.env.RESEND_WEBHOOK_SECRET);

app.post('/webhooks/resend', (req, res) => {
  try {
    const payload = webhook.verify(
      JSON.stringify(req.body),
      {
        'svix-id': req.headers['svix-id'],
        'svix-timestamp': req.headers['svix-timestamp'],
        'svix-signature': req.headers['svix-signature'],
      }
    );
    // Process verified payload
  } catch (err) {
    return res.status(400).send('Invalid signature');
  }
});

3. Register Webhook URL

Configure your webhook endpoint in the Resend dashboard or via API.

Processing Events

Bounce Handling

typescript
async function handleBounce(event) {
  const { email_id, email, bounce_type } = event.data;

  if (bounce_type === 'hard') {
    // Permanent failure - remove from all lists
    await suppressEmail(email, 'hard_bounce');
    await removeFromAllLists(email);
  } else {
    // Soft bounce - track and remove after threshold
    await incrementSoftBounce(email);
    const count = await getSoftBounceCount(email);
    if (count >= 3) {
      await suppressEmail(email, 'soft_bounce_limit');
    }
  }
}

Complaint Handling

typescript
async function handleComplaint(event) {
  const { email } = event.data;

  // Immediate suppression - no exceptions
  await suppressEmail(email, 'complaint');
  await removeFromAllLists(email);
  await logComplaint(event); // For analysis
}

Delivery Confirmation

typescript
async function handleDelivered(event) {
  const { email_id } = event.data;
  await updateEmailStatus(email_id, 'delivered');
}

Idempotent Processing

Webhooks may be sent multiple times. Use event IDs to prevent duplicate processing.

typescript
async function processWebhook(event) {
  const eventId = event.id;

  // Check if already processed
  if (await isEventProcessed(eventId)) {
    return; // Skip duplicate
  }

  // Process event
  await handleEvent(event);

  // Mark as processed
  await markEventProcessed(eventId);
}

Error Handling

Retry Behavior

If your endpoint returns non-2xx, webhooks will retry with exponential backoff:

  • Retry 1: ~30 seconds
  • Retry 2: ~1 minute
  • Retry 3: ~5 minutes
  • (continues for ~24 hours)

Best Practices

  • Return 200 quickly - Process asynchronously to avoid timeouts
  • Be idempotent - Handle duplicate deliveries gracefully
  • Log everything - Store raw events for debugging
  • Alert on failures - Monitor webhook processing errors
  • Queue for processing - Use a job queue for complex handling

Testing Webhooks

Local development: Use ngrok or similar to expose localhost.

bash
ngrok http 3000
# Use the ngrok URL as your webhook endpoint

Verify handling: Send test events through Resend dashboard or manually trigger each event type.

Ingest webhooks for data storage