Files
nixos-config/profiles/opencode/skill/email-best-practices/resources/webhooks-events.md
2026-01-24 20:22:18 +00:00

4.1 KiB

Webhooks and Events

Receiving and processing email delivery events in real-time.

Event Types

Event When Fired Use For
email.sent Email accepted by Resend Confirming send initiated
email.delivered Email delivered to recipient server Confirming delivery
email.bounced Email bounced (hard or soft) List hygiene, alerting
email.complained Recipient marked as spam Immediate unsubscribe
email.opened Recipient opened email Engagement tracking
email.clicked Recipient clicked link Engagement tracking

Webhook Setup

1. Create Endpoint

Your endpoint must:

  • Accept POST requests
  • Return 2xx status quickly (within 5 seconds)
  • Handle duplicate events (idempotent processing)
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.

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

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

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

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.

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.

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.