# Delivery status via webhooks, not polling

- **Pattern:** `ab-002460` (`sending-pipeline-infrastructure.esp-integration.webhook-delivery-status`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002460
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002460)

## Why it matters

Polling the ESP's message status API on a timer introduces latency (you learn about a hard bounce minutes later, not seconds), consumes API quota on every poll cycle, and misses events that occur between polling intervals. Gmail and Yahoo's 2024 bulk sender requirements mandate one-click unsubscribe processing — a webhook-less implementation cannot honor those events promptly. CWE-345 covers insufficient verification of data authenticity; a webhook without HMAC signature verification lets anyone inject fake delivery events and manipulate your suppression list.

## Severity rationale

High because missing webhook processing means delivery failures, bounces, and unsubscribe events are never recorded, leading to repeated sends to invalid addresses and sender reputation damage.

## Remediation

Register a webhook endpoint at `app/api/webhooks/sendgrid/route.ts` (Next.js App Router) that verifies the HMAC signature before processing events:

```ts
const signature = req.headers.get('x-twilio-email-event-webhook-signature') ?? ''
const timestamp = req.headers.get('x-twilio-email-event-webhook-timestamp') ?? ''
const payload = timestamp + await req.text()
const computed = crypto.createHmac('sha256', process.env.SENDGRID_WEBHOOK_SECRET!)
  .update(payload).digest('base64')
if (computed !== signature) return new Response('Invalid signature', { status: 401 })
```

Process at minimum `delivered`, `bounce`, and `spamreport` event types and update your `emailSendLog` table accordingly.

## Detection

- **ID:** `webhook-delivery-status`
- **Severity:** `high`
- **What to look for:** Check how the application learns about delivery status (delivered, bounced, complained, unsubscribed). Look for background jobs that call the ESP's message status API on a timer (polling). The correct pattern is to register a webhook endpoint that the ESP calls when a delivery event occurs. Look for a webhook handler route (`/api/webhooks/email`, `/api/events/sendgrid`, etc.) and verify it processes ESP events.
- **Pass criteria:** A webhook endpoint exists that accepts delivery event payloads from the ESP. Webhook signatures are verified via HMAC validation. The handler processes at least 3 event types (delivered, bounced, complained) and updates the internal delivery status model. Count the number of delivery event types handled.
- **Fail criteria:** Delivery status is determined by polling the ESP's status API on a timer. No webhook endpoint exists. Delivery events are never recorded in the application's database. Or webhook exists but no signature verification.
- **Skip (N/A) when:** The application uses a one-way send model with no delivery tracking — confirmed by the absence of delivery status fields in the schema.
- **Detail on fail:** `"No webhook endpoint registered for ESP delivery events — delivery status never recorded in the database"` or `"Background job polls SendGrid message status API every 5 minutes — inefficient, delayed, and subject to rate limits"`
- **Remediation:** Register a webhook endpoint and handle ESP delivery events:

  ```ts
  // app/api/webhooks/sendgrid/route.ts (Next.js App Router)
  import crypto from 'crypto'
  import { db } from '@/lib/db'

  export async function POST(req: Request) {
    // Verify SendGrid webhook signature
    const signature = req.headers.get('x-twilio-email-event-webhook-signature') ?? ''
    const timestamp = req.headers.get('x-twilio-email-event-webhook-timestamp') ?? ''
    const body = await req.text()

    const payload = timestamp + body
    const computed = crypto
      .createHmac('sha256', process.env.SENDGRID_WEBHOOK_SECRET!)
      .update(payload)
      .digest('base64')

    if (computed !== signature) {
      return new Response('Invalid signature', { status: 401 })
    }

    const events: Array<{ email: string; event: string; sg_message_id: string }> = JSON.parse(body)

    for (const event of events) {
      await db.emailSendLog.updateMany({
        where: { espMessageId: event.sg_message_id },
        data: { deliveryStatus: event.event, updatedAt: new Date() }
      })
    }

    return new Response('OK', { status: 200 })
  }
  ```

## External references

- cwe CWE-345
- external gmail-yahoo-bulk-sender-2024 — https://support.google.com/mail/answer/81126

Taxons: data-integrity, error-resilience

HTML version: https://auditbuffet.com/patterns/ab-002460
