Home/Blog/SendGrid Webhooks: Complete Guide with Payload Examples [2025]
Developer Tools

SendGrid Webhooks: Complete Guide with Payload Examples [2025]

Complete guide to SendGrid Event Webhooks with setup instructions, ECDSA signature verification, payload examples, and implementation code in Node.js, Python, and PHP. Learn how to track email deliveries, opens, clicks, and bounces in real-time.

By Inventive HQ Team
SendGrid Webhooks: Complete Guide with Payload Examples [2025]

When a customer opens your marketing email or their payment confirmation bounces back, you need to know immediately—not when your polling script runs in 5 minutes. SendGrid Event Webhooks solve this problem by sending real-time HTTP POST notifications to your server the moment email events occur, enabling you to:

  • Update delivery status instantly in your database when emails are delivered or bounce
  • Track engagement metrics with precise open and click timestamps for analytics
  • Automate follow-ups by triggering workflows when recipients interact with emails
  • Maintain email reputation by automatically handling unsubscribes and spam reports
  • Debug email issues with detailed event data including SMTP IDs and bounce reasons

SendGrid's Event Webhook uses industry-standard ECDSA (Elliptic Curve Digital Signature Algorithm) signature verification to ensure every notification authentically comes from SendGrid, protecting your application from spoofed requests. In this comprehensive guide, you'll learn how to set up SendGrid webhooks, implement secure signature verification in Node.js, Python, and PHP, handle all event types, and build production-ready webhook endpoints.

Testing your webhook integration is crucial before going live. Our Webhook Payload Generator tool lets you create properly signed SendGrid webhook payloads with custom event data, allowing you to test your signature verification logic and event handlers without exposing your local development environment.

What Are SendGrid Webhooks?

SendGrid Event Webhooks are HTTP POST callbacks that Twilio SendGrid sends to your specified endpoint URL whenever email events occur in their system. Unlike traditional API polling where your application repeatedly queries SendGrid's servers asking "did anything happen?", webhooks invert this model—SendGrid proactively notifies your server the instant an email is processed, delivered, opened, clicked, bounced, or encounters any tracked event.

The webhook architecture follows this flow:

[Email Event Occurs] → [SendGrid Server] → [HTTP POST to Your Endpoint] → [Your Application Logic]

SendGrid webhooks differ from generic webhooks in several important ways:

  1. Batch Delivery Format: SendGrid sends events as an array of JSON objects, not individual events, allowing efficient processing of multiple simultaneous email activities
  2. ECDSA Signature Security: Uses Elliptic Curve cryptography with public/private key pairs for signature verification, providing stronger security than HMAC-SHA256
  3. Dual Verification Headers: Provides both X-Twilio-Email-Event-Webhook-Signature and X-Twilio-Email-Event-Webhook-Timestamp headers for comprehensive security
  4. 24-Hour Retry Window: Automatically retries failed deliveries with exponential backoff for up to 24 hours, ensuring reliable event delivery

Benefits specific to SendGrid webhooks:

  • Real-time tracking of 12+ event types across delivery, engagement, and reputation categories
  • Built-in integration with SendGrid's Email Activity API for event reconciliation
  • Support for custom arguments and categories passed through the sending API
  • Webhook-specific public key management separate from API keys for improved security

Prerequisites for setting up SendGrid webhooks:

  • Active Twilio SendGrid account (Free tier supports webhooks)
  • Publicly accessible HTTPS endpoint (localhost won't work without tunneling)
  • SSL/TLS certificate on your webhook endpoint (required for security)
  • Ability to handle HTTP POST requests with JSON payloads
  • (Recommended) SendGrid API v3 access for retrieving public keys programmatically

Setting Up SendGrid Webhooks

Follow these step-by-step instructions to configure SendGrid Event Webhooks in your account:

Step 1: Access Webhook Settings

  1. Log in to your Twilio SendGrid account at sendgrid.com
  2. Navigate to Settings in the left sidebar
  3. Click Mail Settings
  4. Scroll to find Event Webhooks (or use search)
  5. Click Event Webhooks to open the configuration page

Step 2: Create New Webhook

  1. Click Create new webhook button (top right)
  2. The webhook configuration form will appear with several sections

Step 3: Configure Webhook URL

In the Post URL field, enter your webhook endpoint URL:

https://yourdomain.com/webhooks/sendgrid

Important considerations:

  • URL must use HTTPS (HTTP not supported)
  • Must be publicly accessible (SendGrid servers need to reach it)
  • Should be a dedicated endpoint for SendGrid events
  • Path can be customized to your application structure

Optional: Friendly Name

Enter a descriptive name (e.g., "Production Email Events" or "Marketing Campaign Tracker") to identify this webhook if you configure multiple endpoints.

Step 4: Select Event Types

Under Actions to be posted, check the boxes for events you want to receive:

Delivery Events:

  • ☐ Processed - Email accepted by SendGrid
  • ☐ Delivered - Successfully delivered to recipient's server
  • ☐ Deferred - Temporarily rejected by receiving server
  • ☐ Bounce - Permanently rejected (hard bounce)
  • ☐ Blocked - Temporarily rejected (soft bounce)
  • ☐ Dropped - Rejected before sending (invalid address, unsubscribed, etc.)

Engagement Events:

  • ☐ Open - Recipient opened the email (requires Open Tracking enabled)
  • ☐ Click - Recipient clicked a link (requires Click Tracking enabled)
  • ☐ Spam Report - Recipient marked as spam
  • ☐ Unsubscribe - Recipient clicked unsubscribe link
  • ☐ Group Unsubscribe - Unsubscribed from specific group
  • ☐ Group Resubscribe - Resubscribed to specific group

Pro Tip: Only subscribe to events your application actually processes to reduce payload size and improve performance.

Step 5: Enable Security Features

Under Security features, enable Signed Event Webhook:

  1. Toggle Enable Signed Event Webhook to ON
  2. Click Save to generate the cryptographic key pair
  3. The public key will be displayed after saving (copy it securely)

The public verification key looks like this:

MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEDr2LjtURuePQzplybdC+u4CwrqDqBaWjcMMsTbhdbcwHBcVs8F7D1DXSL4kFvTXkj6svqUzTMZ5pKLGb2GdRA==

Store this key securely in your application's environment variables.

Step 6: Test Your Integration

SendGrid provides a built-in test feature:

  1. Scroll to the Test Your Integration section
  2. Click Test Your Integration button
  3. SendGrid sends sample JSON event data to your endpoint
  4. Verify your endpoint receives the test payload and returns 200 status

Step 7: Enable the Webhook

  1. Toggle the Enabled switch to ON
  2. Click Save to activate the webhook
  3. Events will now be sent to your endpoint in real-time

Where to Find Your Public Key Later

To retrieve your public key after initial setup:

  1. Navigate back to Settings > Mail Settings > Event Webhooks
  2. Click the webhook name to edit
  3. The public key is displayed in the Security features section
  4. You can also retrieve it programmatically using the Get Signed Event Webhook's Public Key API endpoint

Pro Tips for Setup

Best Practices:

  • Use separate webhooks for test/sandbox and production environments
  • Configure different endpoints for different email categories (transactional vs. marketing)
  • Enable all event types initially for debugging, then narrow down to only needed events
  • Set up monitoring alerts for webhook delivery failures in SendGrid's dashboard
  • Document your public key location for team members

Common Mistakes to Avoid:

  • ❌ Using HTTP instead of HTTPS (will fail validation)
  • ❌ Forgetting to enable signature verification (security risk)
  • ❌ Not testing before enabling (broken endpoints cause event loss)
  • ❌ Subscribing to all events when only needing a few (unnecessary processing)
  • ❌ Using localhost URLs without ngrok tunneling (SendGrid can't reach them)

Rate Limits and Restrictions:

  • No explicit rate limits on webhook deliveries (events sent as they occur)
  • Maximum webhook URL length: 2000 characters
  • Maximum retry period: 24 hours with exponential backoff
  • Batch sizes vary based on email volume (typically 1-100 events per POST)

SendGrid Webhook Events & Payloads

SendGrid sends webhook payloads as an array of event objects, allowing your endpoint to process multiple events in a single request. Each event includes common fields plus event-specific data.

Event Types Overview

Event TypeDescriptionCommon Use Case
processedSendGrid accepted the email and queued it for deliveryTrack email acceptance and initial processing
deliveredEmail successfully delivered to recipient's mail serverUpdate delivery status, confirm successful sending
deferredReceiving server temporarily rejected the messageMonitor delivery delays, retry logic validation
bouncePermanent delivery failure (hard bounce)Remove invalid addresses, update contact status
blockedTemporary delivery failure due to ISP blockingTrack reputation issues, adjust sending practices
droppedSendGrid dropped email before sendingIdentify suppression list hits, invalid addresses
openRecipient opened the HTML emailTrack engagement metrics, trigger follow-ups
clickRecipient clicked a link in the emailMeasure campaign effectiveness, conversion tracking
spam_reportRecipient marked email as spamUpdate suppression lists, monitor sender reputation
unsubscribeRecipient clicked "Opt Out of All Emails" linkProcess global unsubscribe requests, update preferences
group_unsubscribeRecipient unsubscribed from specific suppression groupManage category-specific preferences
group_resubscribeRecipient resubscribed to specific groupRe-enable communications for specific categories

Common Fields Across All Events

Every event includes these base fields:

  • email - Recipient email address
  • timestamp - Unix timestamp when event occurred
  • event - Event type (delivered, open, click, etc.)
  • sg_event_id - Unique identifier for this webhook event (use for idempotency)
  • sg_message_id - SendGrid's internal message ID (links to Email Activity API)

Detailed Event Examples

Event: delivered

Description: The receiving mail server accepted the message and confirmed delivery.

Payload Structure:

[
  {
    "email": "[email protected]",
    "timestamp": 1706097600,
    "smtp-id": "<14c5d75ce93.dfd.64b469@ismtpd-555>",
    "event": "delivered",
    "category": ["transactional", "password-reset"],
    "sg_event_id": "ZGVsaXZlcmVkLTAtMTIzNDU2Nzg5LWFiY2RlZg",
    "sg_message_id": "14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0",
    "response": "250 2.0.0 OK  1706097600 abc123def456",
    "ip": "192.168.1.100",
    "tls": 1,
    "cert_err": 0
  }
]

Key Fields:

  • smtp-id - SMTP transaction ID for debugging email delivery
  • response - SMTP response from receiving server
  • ip - IP address SendGrid used to send the email
  • tls - Whether TLS encryption was used (1 = yes, 0 = no)
  • cert_err - Certificate error flag (1 = error, 0 = no error)

Event: open

Description: The recipient opened the email (requires Open Tracking enabled in SendGrid settings).

Payload Structure:

[
  {
    "email": "[email protected]",
    "timestamp": 1706098200,
    "event": "open",
    "category": ["marketing", "newsletter"],
    "sg_event_id": "b3BlbmVkLTEtMTIzNDU2Nzg5LXh5emFiYw",
    "sg_message_id": "14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0",
    "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
    "ip": "203.0.113.42"
  }
]

Key Fields:

  • useragent - Browser/email client user agent string (useful for device detection)
  • ip - IP address from which the email was opened

Important Note: Open tracking uses a 1x1 pixel image. Opens may be undercounted if images are blocked, or overcounted if email clients pre-load images.

Event: click

Description: The recipient clicked a tracked link in the email (requires Click Tracking enabled).

Payload Structure:

[
  {
    "email": "[email protected]",
    "timestamp": 1706098800,
    "event": "click",
    "category": ["promotional"],
    "sg_event_id": "Y2xpY2tlZC0yLTEyMzQ1Njc4OS1wcXJzdHV2",
    "sg_message_id": "14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0",
    "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
    "ip": "203.0.113.42",
    "url": "https://example.com/special-offer?utm_source=email&utm_campaign=january",
    "url_offset": {
      "index": 0,
      "type": "html"
    }
  }
]

Key Fields:

  • url - The actual destination URL that was clicked (not the click-tracking redirect)
  • url_offset - Position and type of link in the email (useful for A/B testing link placement)

Event: bounce

Description: The receiving mail server permanently rejected the message (hard bounce).

Payload Structure:

[
  {
    "email": "[email protected]",
    "timestamp": 1706097700,
    "event": "bounce",
    "category": ["transactional"],
    "sg_event_id": "Ym91bmNlZC0zLTEyMzQ1Njc4OS13eHl6YWJj",
    "sg_message_id": "14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0",
    "reason": "550 5.1.1 The email account that you tried to reach does not exist.",
    "status": "5.1.1",
    "type": "bounce",
    "smtp-id": "<14c5d75ce93.dfd.64b469@ismtpd-555>",
    "ip": "192.168.1.100",
    "tls": 1,
    "cert_err": 0
  }
]

Key Fields:

  • reason - Full SMTP error message explaining why delivery failed
  • status - SMTP status code (5.x.x = permanent failure, 4.x.x = temporary)
  • type - Bounce type classification (bounce, blocked, etc.)

Action Required: Remove hard-bounced addresses from your mailing lists to maintain sender reputation.

Event: dropped

Description: SendGrid dropped the email before attempting delivery due to suppression list, invalid address, or policy violation.

Payload Structure:

[
  {
    "email": "[email protected]",
    "timestamp": 1706097500,
    "event": "dropped",
    "category": ["newsletter"],
    "sg_event_id": "ZHJvcHBlZC00LTEyMzQ1Njc4OS1hYmNkZWY",
    "sg_message_id": "14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0",
    "reason": "Unsubscribed Address",
    "smtp-id": "<14c5d75ce93.dfd.64b469@ismtpd-555>"
  }
]

Key Fields:

  • reason - Why SendGrid dropped the email (common reasons: "Unsubscribed Address", "Bounced Address", "Spam Reporting Address", "Invalid")

Common Drop Reasons:

  • Unsubscribed Address - Recipient on your suppression list
  • Bounced Address - Previously bounced (SendGrid maintains bounce list)
  • Spam Reporting Address - Previously reported your emails as spam
  • Invalid - Malformed email address

Event: spam_report

Description: The recipient marked your email as spam using their email client's report button.

Payload Structure:

[
  {
    "email": "[email protected]",
    "timestamp": 1706099000,
    "event": "spamreport",
    "category": ["marketing"],
    "sg_event_id": "c3BhbXJlcG9ydC01LTEyMzQ1Njc4OS14eXphYmM",
    "sg_message_id": "14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0"
  }
]

Action Required: Immediately add to suppression list and investigate why recipient marked as spam. High spam report rates severely damage sender reputation.

Event: unsubscribe

Description: Recipient clicked the global "Opt Out of All Emails" unsubscribe link.

Payload Structure:

[
  {
    "email": "[email protected]",
    "timestamp": 1706099200,
    "event": "unsubscribe",
    "category": ["promotional"],
    "sg_event_id": "dW5zdWJzY3JpYmUtNi0xMjM0NTY3ODktcHFyc3R1",
    "sg_message_id": "14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0"
  }
]

Action Required: Honor unsubscribe requests immediately (CAN-SPAM Act requires compliance within 10 business days). Remove from all mailing lists.

Batch Payload Format

SendGrid may send multiple events in a single webhook POST:

[
  {
    "email": "[email protected]",
    "timestamp": 1706097600,
    "event": "delivered",
    "sg_event_id": "event-1"
  },
  {
    "email": "[email protected]",
    "timestamp": 1706097601,
    "event": "open",
    "sg_event_id": "event-2"
  },
  {
    "email": "[email protected]",
    "timestamp": 1706097602,
    "event": "click",
    "sg_event_id": "event-3"
  }
]

Your endpoint must process arrays of events, not just individual objects.

Webhook Signature Verification

Why Signature Verification Matters

Without signature verification, attackers could:

  • Spoof webhook requests by sending fake events to your endpoint
  • Manipulate data by injecting false delivery confirmations or unsubscribe requests
  • Trigger unintended actions like removing valid email addresses from your lists
  • Exploit business logic by fabricating engagement metrics

SendGrid's ECDSA signature verification cryptographically proves that webhook requests genuinely originated from SendGrid's servers, preventing all these attack vectors.

SendGrid's Signature Method

Algorithm: ECDSA (Elliptic Curve Digital Signature Algorithm) with SHA-256 hashing

Header Names:

  • X-Twilio-Email-Event-Webhook-Signature - Base64-encoded ECDSA signature
  • X-Twilio-Email-Event-Webhook-Timestamp - Unix timestamp included in signature calculation

What's Signed: SendGrid creates a signature by:

  1. Concatenating the timestamp bytes + raw request body bytes
  2. Computing SHA-256 hash of the concatenated data
  3. Signing the hash with ECDSA using SendGrid's private key
  4. Base64-encoding the signature for transmission

Additional Security:

  • Timestamp header prevents replay attacks (reject requests older than 5-10 minutes)
  • Public key retrieval via secure API endpoint
  • Separate keys per webhook configuration

Step-by-Step Verification Process

  1. Extract the signature and timestamp from request headers
  2. Retrieve your public key from SendGrid dashboard or API
  3. Get the raw request body (must be unmodified bytes, not parsed JSON)
  4. Concatenate timestamp + payload in the exact order SendGrid used
  5. Compute SHA-256 hash of the concatenated data
  6. Verify ECDSA signature using your public key against the hash
  7. Validate timestamp to ensure request is recent (not replayed)

Code Examples

Node.js / Express

const express = require('express');
const bodyParser = require('body-parser');
const unless = require('express-unless');
const { EventWebhook, EventWebhookHeader } = require('@sendgrid/eventwebhook');

const app = express();

// Verification function
const verifySignature = (publicKey, payload, signature, timestamp) => {
  const eventWebhook = new EventWebhook();
  const ecPublicKey = eventWebhook.convertPublicKeyToECDSA(publicKey);
  return eventWebhook.verifySignature(ecPublicKey, payload, signature, timestamp);
};

// IMPORTANT: Exclude webhook path from JSON parsing
const jsonParser = bodyParser.json();
jsonParser.unless = unless;
app.use(jsonParser.unless({ path: ['/webhooks/sendgrid'] }));

// SendGrid webhook endpoint with raw body parsing
app.post('/webhooks/sendgrid',
  bodyParser.raw({ type: 'application/json' }),
  (req, res) => {
    try {
      // Extract signature and timestamp from headers
      const signature = req.get(EventWebhookHeader.SIGNATURE());
      const timestamp = req.get(EventWebhookHeader.TIMESTAMP());

      // Get raw body (Buffer)
      const payload = req.body;

      // Retrieve public key from environment
      const publicKey = process.env.SENDGRID_WEBHOOK_PUBLIC_KEY;

      // Verify signature
      const isValid = verifySignature(publicKey, payload, signature, timestamp);

      if (!isValid) {
        console.error('Invalid SendGrid webhook signature');
        return res.status(403).json({ error: 'Invalid signature' });
      }

      // Validate timestamp (reject requests older than 10 minutes)
      const currentTime = Math.floor(Date.now() / 1000);
      const requestAge = currentTime - parseInt(timestamp);
      if (requestAge > 600) {
        console.error('Webhook request too old (possible replay attack)');
        return res.status(403).json({ error: 'Request expired' });
      }

      // Parse payload after verification
      const events = JSON.parse(payload.toString());

      // Process events
      console.log(`Received ${events.length} SendGrid event(s)`);
      events.forEach(event => {
        console.log(`- ${event.event} for ${event.email}`);
      });

      // Return 204 immediately (no content needed)
      res.status(204).send();

      // Process async (see Implementation Example section)
      processWebhookAsync(events);

    } catch (error) {
      console.error('SendGrid webhook processing error:', error);
      // Return 500 to trigger SendGrid retry
      res.status(500).json({ error: 'Processing failed' });
    }
  }
);

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`SendGrid webhook server listening on port ${PORT}`);
});

Python / Flask

import hmac
import hashlib
import time
from flask import Flask, request, jsonify
from sendgrid.helpers.eventwebhook import EventWebhook, EventWebhookHeader

app = Flask(__name__)

# Load public key from environment
PUBLIC_KEY = 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...'  # Your public key

@app.route('/webhooks/sendgrid', methods=['POST'])
def sendgrid_webhook():
    try:
        # Get signature and timestamp from headers
        signature = request.headers.get(EventWebhookHeader.SIGNATURE.value)
        timestamp = request.headers.get(EventWebhookHeader.TIMESTAMP.value)

        if not signature or not timestamp:
            return jsonify({'error': 'Missing signature headers'}), 401

        # Get raw body (must be bytes, not parsed JSON)
        payload = request.get_data()

        # Verify signature using SendGrid library
        event_webhook = EventWebhook()
        ec_public_key = event_webhook.convert_public_key_to_ecdsa(PUBLIC_KEY)
        is_valid = event_webhook.verify_signature(
            ec_public_key,
            payload,
            signature,
            timestamp
        )

        if not is_valid:
            print('Invalid SendGrid webhook signature')
            return jsonify({'error': 'Invalid signature'}), 403

        # Validate timestamp (reject if older than 10 minutes)
        current_time = int(time.time())
        request_age = current_time - int(timestamp)
        if request_age > 600:
            print('Webhook request too old (possible replay attack)')
            return jsonify({'error': 'Request expired'}), 403

        # Parse payload after verification
        events = request.get_json()

        # Process events
        print(f'Received {len(events)} SendGrid event(s)')
        for event in events:
            print(f"- {event['event']} for {event['email']}")

        # Return 204 immediately
        return '', 204

    except Exception as error:
        print(f'SendGrid webhook processing error: {error}')
        return jsonify({'error': 'Processing failed'}), 500

if __name__ == '__main__':
    app.run(port=3000, debug=False)

PHP

<?php
// Note: SendGrid does not provide an official PHP library for ECDSA verification
// You'll need to use OpenSSL functions directly

// Get public key from environment
$publicKey = getenv('SENDGRID_WEBHOOK_PUBLIC_KEY');

// Extract headers
$signature = $_SERVER['HTTP_X_TWILIO_EMAIL_EVENT_WEBHOOK_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_TWILIO_EMAIL_EVENT_WEBHOOK_TIMESTAMP'] ?? '';

if (empty($signature) || empty($timestamp)) {
    http_response_code(401);
    die(json_encode(['error' => 'Missing signature headers']));
}

// Get raw POST body (MUST be raw bytes, not parsed)
$payload = file_get_contents('php://input');

// Validate timestamp (reject if older than 10 minutes)
$currentTime = time();
$requestAge = $currentTime - intval($timestamp);
if ($requestAge > 600) {
    error_log('Webhook request too old (possible replay attack)');
    http_response_code(403);
    die(json_encode(['error' => 'Request expired']));
}

// Prepare data for verification (timestamp + payload)
$dataToVerify = $timestamp . $payload;

// Convert public key to PEM format for OpenSSL
$publicKeyPem = "-----BEGIN PUBLIC KEY-----\n" .
    chunk_split($publicKey, 64, "\n") .
    "-----END PUBLIC KEY-----";

// Decode base64 signature
$signatureDecoded = base64_decode($signature);

// Parse ECDSA signature (DER format to r and s components)
// This is complex - consider using a library like phpseclib

// Verify signature using OpenSSL
$publicKeyResource = openssl_pkey_get_public($publicKeyPem);
$isValid = openssl_verify(
    $dataToVerify,
    $signatureDecoded,
    $publicKeyResource,
    OPENSSL_ALGO_SHA256
);
openssl_free_key($publicKeyResource);

if ($isValid !== 1) {
    error_log('Invalid SendGrid webhook signature');
    http_response_code(403);
    die(json_encode(['error' => 'Invalid signature']));
}

// Parse payload after verification
$events = json_decode($payload, true);

// Process events
error_log('Received ' . count($events) . ' SendGrid event(s)');
foreach ($events as $event) {
    error_log("- {$event['event']} for {$event['email']}");
}

// Return 204 immediately
http_response_code(204);

// Process async (implement your queue logic here)
?>

Note: PHP ECDSA verification is more complex than other languages. We recommend using the official SendGrid libraries for Node.js or Python in production, or implementing a microservice for signature verification if PHP is required.

Common Verification Errors

  • Parsing JSON before verification: Body modified, signature won't match
    • ✅ Use raw body parser, verify first, then parse JSON
  • Using wrong public key: Test vs production keys are different
    • ✅ Verify key from SendGrid dashboard matches your environment variable
  • Incorrect key format: Missing BEGIN/END markers or incorrect encoding
    • ✅ Use convertPublicKeyToECDSA() method from SendGrid libraries
  • Not validating timestamp: Vulnerable to replay attacks
    • ✅ Reject requests older than 5-10 minutes
  • Comparing signatures with ==: Vulnerable to timing attacks
    • ✅ Use constant-time comparison functions (built into SendGrid libraries)
  • Whitespace trimming: Modifies raw body, breaks signature
    • ✅ Preserve exact bytes received from SendGrid

Testing SendGrid Webhooks

Local Development Challenges

Testing SendGrid webhooks during development presents several obstacles:

  • SendGrid can't reach localhost: Webhook URLs must be publicly accessible
  • HTTPS requirement: SendGrid only sends to secure HTTPS endpoints
  • Real email dependency: Testing requires sending actual emails through SendGrid
  • Signature generation: Creating valid ECDSA signatures manually is complex

Solution 1: ngrok for Local Testing

ngrok creates a secure tunnel from a public URL to your local development server.

Setup Steps:

# Install ngrok (macOS)
brew install ngrok

# Or download from ngrok.com for other platforms

# Start your local webhook server on port 3000
node server.js

# In another terminal, create ngrok tunnel
ngrok http 3000

# ngrok output will show:
# Forwarding https://abc123def456.ngrok.io -> http://localhost:3000

Configure SendGrid:

  1. Copy the ngrok HTTPS URL (e.g., https://abc123def456.ngrok.io)
  2. Navigate to SendGrid webhook settings
  3. Set Post URL to: https://abc123def456.ngrok.io/webhooks/sendgrid
  4. Save and enable the webhook

Send Test Email:

# Using SendGrid API
curl -X POST https://api.sendgrid.com/v3/mail/send \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "personalizations": [{"to": [{"email": "[email protected]"}]}],
    "from": {"email": "[email protected]"},
    "subject": "Test Webhook Event",
    "content": [{"type": "text/plain", "value": "Testing webhooks"}]
  }'

Your local server will receive webhook events as the email is processed, delivered, and (if opened/clicked) interacted with.

ngrok Pro Tips:

  • Use ngrok http 3000 --log=stdout to see all HTTP traffic
  • ngrok URLs change on restart (use paid plan for static URLs)
  • Review webhook logs in ngrok web interface at http://127.0.0.1:4040

Solution 2: Webhook Payload Generator Tool

For testing without sending real emails or exposing your local environment, use our specialized tool:

Visit Webhook Payload Generator

Testing Workflow:

  1. Select Provider: Choose "SendGrid" from the dropdown
  2. Choose Event Type: Select event (delivered, open, click, bounce, etc.)
  3. Customize Payload: Edit email addresses, timestamps, or event-specific fields
  4. Generate Signature: Tool creates valid ECDSA signature with proper headers
  5. Copy Payload: Get complete HTTP request including headers
  6. Send to Local Endpoint: Use curl or Postman to POST to http://localhost:3000/webhooks/sendgrid

Example cURL Command:

curl -X POST http://localhost:3000/webhooks/sendgrid \
  -H "Content-Type: application/json" \
  -H "X-Twilio-Email-Event-Webhook-Signature: <generated-signature>" \
  -H "X-Twilio-Email-Event-Webhook-Timestamp: 1706097600" \
  -d '[{"email":"[email protected]","timestamp":1706097600,"event":"delivered","sg_event_id":"test-event-1"}]'

Benefits of Webhook Payload Generator:

  • ✅ No ngrok tunneling required
  • ✅ Test signature verification logic without SendGrid account
  • ✅ Customize payload values for edge case testing
  • ✅ Test error handling with malformed payloads
  • ✅ No email sending costs during development
  • ✅ Instant testing without waiting for email delivery

SendGrid's Built-in Testing Features

Test Integration Button:

  1. Navigate to webhook settings in SendGrid dashboard
  2. Click Test Your Integration button
  3. SendGrid sends sample event data to your configured endpoint
  4. Check your endpoint logs to verify receipt

Email Activity API (Backup Verification):

If webhooks aren't arriving, verify events are occurring:

curl -X GET "https://api.sendgrid.com/v3/messages?limit=10" \
  -H "Authorization: Bearer YOUR_API_KEY"

Compare events in Email Activity with webhooks received to identify delivery gaps.

Webhook Delivery Logs:

SendGrid tracks webhook delivery attempts:

  1. Go to webhook configuration
  2. Scroll to delivery logs section
  3. Review status codes, retry attempts, and error messages
  4. Use this to debug endpoint issues

Testing Checklist

Before deploying to production, verify:

  • Signature verification passes with valid SendGrid public key
  • Endpoint returns 204 within SendGrid's timeout (10 seconds)
  • Idempotent processing handles duplicate sg_event_id gracefully
  • Error handling catches malformed payloads without crashing
  • Async processing queues events instead of blocking response
  • Timestamp validation rejects old requests (replay attack prevention)
  • All event types are handled (or safely ignored)
  • Logging captures event details for debugging
  • Monitoring alerts on high error rates or missing webhooks
  • Database storage of event IDs prevents duplicate processing

Test Each Event Type:

Use the Webhook Payload Generator to send:

  • delivered event
  • open event (with useragent)
  • click event (with URL)
  • bounce event (with reason)
  • dropped event
  • spam_report event
  • unsubscribe event

Verify your application responds correctly to each scenario.

Implementation Example

Building a production-ready SendGrid webhook endpoint requires more than basic signature verification. You need proper error handling, async processing, idempotency checks, and monitoring.

Requirements for Production Endpoints

  • Respond within 10 seconds: SendGrid times out after 10s, triggering retries
  • Return 2xx status codes: 200 or 204 indicates success (any other status triggers retry)
  • Process asynchronously: Queue events for background processing to respond quickly
  • Implement idempotency: Use sg_event_id to prevent duplicate event processing
  • Handle retries gracefully: SendGrid retries with exponential backoff for up to 24 hours
  • Log comprehensively: Track all events for debugging and reconciliation

Complete Node.js Production Example

This example uses Express, Bull queue for async processing, and Prisma for database operations.

const express = require('express');
const bodyParser = require('body-parser');
const unless = require('express-unless');
const Queue = require('bull');
const { EventWebhook, EventWebhookHeader } = require('@sendgrid/eventwebhook');
const { PrismaClient } = require('@prisma/client');

const app = express();
const prisma = new PrismaClient();

// Create Bull queue for async webhook processing
const webhookQueue = new Queue('sendgrid-webhooks', {
  redis: {
    host: process.env.REDIS_HOST || 'localhost',
    port: process.env.REDIS_PORT || 6379
  }
});

// Exclude webhook path from JSON parsing (must use raw body)
const jsonParser = bodyParser.json();
jsonParser.unless = unless;
app.use(jsonParser.unless({ path: ['/webhooks/sendgrid'] }));

// Signature verification helper
const verifySignature = (publicKey, payload, signature, timestamp) => {
  const eventWebhook = new EventWebhook();
  const ecPublicKey = eventWebhook.convertPublicKeyToECDSA(publicKey);
  return eventWebhook.verifySignature(ecPublicKey, payload, signature, timestamp);
};

// SendGrid webhook endpoint
app.post('/webhooks/sendgrid',
  bodyParser.raw({ type: 'application/json' }),
  async (req, res) => {
    const startTime = Date.now();

    try {
      // 1. Extract headers
      const signature = req.get(EventWebhookHeader.SIGNATURE());
      const timestamp = req.get(EventWebhookHeader.TIMESTAMP());
      const payload = req.body;

      // 2. Verify signature
      const publicKey = process.env.SENDGRID_WEBHOOK_PUBLIC_KEY;
      const isValid = verifySignature(publicKey, payload, signature, timestamp);

      if (!isValid) {
        console.error('[SendGrid] Invalid signature');
        return res.status(403).json({ error: 'Invalid signature' });
      }

      // 3. Validate timestamp (reject requests older than 10 minutes)
      const currentTime = Math.floor(Date.now() / 1000);
      const requestAge = currentTime - parseInt(timestamp);
      if (requestAge > 600) {
        console.error('[SendGrid] Request expired', { age: requestAge });
        return res.status(403).json({ error: 'Request expired' });
      }

      // 4. Parse events
      const events = JSON.parse(payload.toString());
      console.log(`[SendGrid] Received ${events.length} event(s)`);

      // 5. Queue each event for async processing
      const queuePromises = events.map(async (event) => {
        const eventId = event.sg_event_id;

        // Check if already processed (idempotency)
        const exists = await prisma.webhookEvent.findUnique({
          where: { eventId }
        });

        if (exists) {
          console.log(`[SendGrid] Event ${eventId} already processed, skipping`);
          return { eventId, duplicate: true };
        }

        // Add to processing queue
        await webhookQueue.add({
          eventId,
          eventType: event.event,
          email: event.email,
          timestamp: event.timestamp,
          rawEvent: event
        }, {
          attempts: 3,
          backoff: {
            type: 'exponential',
            delay: 2000
          }
        });

        return { eventId, queued: true };
      });

      await Promise.all(queuePromises);

      // 6. Return 204 immediately (successful, no content needed)
      const processingTime = Date.now() - startTime;
      console.log(`[SendGrid] Processed in ${processingTime}ms`);
      res.status(204).send();

    } catch (error) {
      console.error('[SendGrid] Webhook processing error:', error);

      // Return 500 to trigger SendGrid retry
      // (Don't return 200 if processing failed)
      res.status(500).json({ error: 'Internal server error' });
    }
  }
);

// Process webhooks from queue
webhookQueue.process(async (job) => {
  const { eventId, eventType, email, timestamp, rawEvent } = job.data;

  try {
    console.log(`[Queue] Processing ${eventType} event ${eventId}`);

    // Mark as processing in database
    await prisma.webhookEvent.create({
      data: {
        eventId,
        eventType,
        email,
        timestamp: new Date(timestamp * 1000),
        status: 'processing',
        rawPayload: JSON.stringify(rawEvent)
      }
    });

    // Handle different event types
    switch (eventType) {
      case 'delivered':
        await handleDelivered(rawEvent);
        break;

      case 'open':
        await handleOpen(rawEvent);
        break;

      case 'click':
        await handleClick(rawEvent);
        break;

      case 'bounce':
        await handleBounce(rawEvent);
        break;

      case 'dropped':
        await handleDropped(rawEvent);
        break;

      case 'spamreport':
        await handleSpamReport(rawEvent);
        break;

      case 'unsubscribe':
        await handleUnsubscribe(rawEvent);
        break;

      default:
        console.warn(`[Queue] Unhandled event type: ${eventType}`);
    }

    // Mark as completed
    await prisma.webhookEvent.update({
      where: { eventId },
      data: {
        status: 'completed',
        processedAt: new Date()
      }
    });

    console.log(`[Queue] Completed ${eventType} event ${eventId}`);

  } catch (error) {
    console.error(`[Queue] Failed to process event ${eventId}:`, error);

    // Mark as failed
    await prisma.webhookEvent.update({
      where: { eventId },
      data: {
        status: 'failed',
        errorMessage: error.message,
        failedAt: new Date()
      }
    });

    throw error; // Trigger Bull queue retry
  }
});

// Business logic handlers

async function handleDelivered(event) {
  // Update email delivery status in your system
  await prisma.emailLog.updateMany({
    where: { sendgridMessageId: event.sg_message_id },
    data: {
      status: 'delivered',
      deliveredAt: new Date(event.timestamp * 1000),
      smtpId: event['smtp-id']
    }
  });

  console.log(`[Handler] Marked ${event.email} as delivered`);
}

async function handleOpen(event) {
  // Track email opens for analytics
  await prisma.emailEngagement.create({
    data: {
      emailAddress: event.email,
      messageId: event.sg_message_id,
      eventType: 'open',
      timestamp: new Date(event.timestamp * 1000),
      userAgent: event.useragent,
      ipAddress: event.ip
    }
  });

  console.log(`[Handler] Recorded open from ${event.email}`);
}

async function handleClick(event) {
  // Track link clicks
  await prisma.emailEngagement.create({
    data: {
      emailAddress: event.email,
      messageId: event.sg_message_id,
      eventType: 'click',
      timestamp: new Date(event.timestamp * 1000),
      userAgent: event.useragent,
      ipAddress: event.ip,
      clickedUrl: event.url
    }
  });

  // Trigger follow-up workflows based on clicked URL
  if (event.url.includes('/special-offer')) {
    await triggerOfferWorkflow(event.email);
  }

  console.log(`[Handler] Recorded click on ${event.url} from ${event.email}`);
}

async function handleBounce(event) {
  // Mark email as bounced and remove from active lists
  await prisma.contact.updateMany({
    where: { email: event.email },
    data: {
      bounceStatus: 'hard_bounce',
      bouncedAt: new Date(event.timestamp * 1000),
      bounceReason: event.reason,
      emailValid: false
    }
  });

  // Alert team about bounce patterns
  console.error(`[Handler] BOUNCE: ${event.email} - ${event.reason}`);
}

async function handleDropped(event) {
  // Log dropped emails for investigation
  await prisma.droppedEmail.create({
    data: {
      email: event.email,
      reason: event.reason,
      droppedAt: new Date(event.timestamp * 1000),
      messageId: event.sg_message_id
    }
  });

  console.warn(`[Handler] DROPPED: ${event.email} - ${event.reason}`);
}

async function handleSpamReport(event) {
  // CRITICAL: Immediately suppress this address
  await prisma.contact.updateMany({
    where: { email: event.email },
    data: {
      spamReported: true,
      reportedAt: new Date(event.timestamp * 1000),
      unsubscribed: true,
      emailValid: false
    }
  });

  // Alert compliance team
  console.error(`[Handler] SPAM REPORT: ${event.email}`);
  await alertComplianceTeam(event);
}

async function handleUnsubscribe(event) {
  // Honor unsubscribe immediately (CAN-SPAM compliance)
  await prisma.contact.updateMany({
    where: { email: event.email },
    data: {
      unsubscribed: true,
      unsubscribedAt: new Date(event.timestamp * 1000),
      unsubscribeMethod: 'email_link'
    }
  });

  console.log(`[Handler] UNSUBSCRIBED: ${event.email}`);
}

// Helper functions

async function triggerOfferWorkflow(email) {
  // Example: Send follow-up email after user clicks special offer
  console.log(`[Workflow] Triggering offer workflow for ${email}`);
  // Your workflow logic here
}

async function alertComplianceTeam(event) {
  // Example: Send Slack notification to compliance team
  console.log(`[Alert] Notifying compliance team about spam report from ${event.email}`);
  // Your alerting logic here
}

// Health check endpoint
app.get('/health', (req, res) => {
  res.json({
    status: 'ok',
    timestamp: new Date().toISOString(),
    queueJobs: webhookQueue.getJobCounts()
  });
});

// Graceful shutdown
process.on('SIGTERM', async () => {
  console.log('SIGTERM received, closing server gracefully');
  await webhookQueue.close();
  await prisma.$disconnect();
  process.exit(0);
});

// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`SendGrid webhook server listening on port ${PORT}`);
  console.log(`Endpoint: POST /webhooks/sendgrid`);
});

Key Implementation Details

  1. Raw Body Parsing: Critical for signature verification—JSON parsing modifies the payload
  2. Timestamp Validation: Prevents replay attacks by rejecting old requests
  3. Idempotency Check: Uses sg_event_id to detect and skip duplicate events
  4. Queue-Based Processing: Responds to SendGrid in <10s, processes events asynchronously
  5. Error Handling: Returns 500 (not 200) on processing failures to trigger retries
  6. Comprehensive Logging: Tracks all events with structured logging for debugging
  7. Database Tracking: Stores event IDs, statuses, and timestamps for reconciliation
  8. Event-Specific Handlers: Different business logic per event type

Database Schema Example (Prisma)

model WebhookEvent {
  id            String   @id @default(cuid())
  eventId       String   @unique // sg_event_id
  eventType     String   // delivered, open, click, etc.
  email         String
  timestamp     DateTime
  status        String   // processing, completed, failed
  rawPayload    String   @db.Text
  errorMessage  String?
  processedAt   DateTime?
  failedAt      DateTime?
  createdAt     DateTime @default(now())

  @@index([eventType, createdAt])
  @@index([email, eventType])
}

model EmailLog {
  id                 String    @id @default(cuid())
  sendgridMessageId  String    @unique
  email              String
  subject            String
  status             String    // sent, delivered, bounced, dropped
  sentAt             DateTime
  deliveredAt        DateTime?
  smtpId             String?
  createdAt          DateTime  @default(now())

  @@index([email, status])
}

model EmailEngagement {
  id           String   @id @default(cuid())
  emailAddress String
  messageId    String   // sg_message_id
  eventType    String   // open, click
  timestamp    DateTime
  userAgent    String?
  ipAddress    String?
  clickedUrl   String?
  createdAt    DateTime @default(now())

  @@index([messageId, eventType])
  @@index([emailAddress, timestamp])
}

Best Practices

Security

  • Always verify signatures: Never process unverified webhooks in production
  • Use HTTPS only: SendGrid requires HTTPS; enforce TLS 1.2+ on your server
  • Store secrets securely: Keep public keys in environment variables, never commit to git
  • Validate timestamps: Reject requests older than 5-10 minutes to prevent replay attacks
  • Rate limit endpoints: Protect against abuse even though requests are authenticated
  • IP whitelisting: SendGrid doesn't publish IP ranges, but you can monitor and allowlist observed IPs
  • Isolate webhook endpoints: Don't expose webhook paths in public documentation
  • Use dedicated API keys: Separate SendGrid API keys for sending vs webhook management

Performance

  • Respond within 10 seconds: SendGrid's timeout—use async processing for anything longer
  • Return 204 immediately: Acknowledge receipt, process later (don't wait for business logic)
  • Use queue systems: Redis/Bull, RabbitMQ, AWS SQS for async processing
  • Implement connection pooling: Database connections should be pooled, not created per request
  • Monitor webhook processing times: Alert if p95 latency approaches timeout threshold
  • Batch database operations: Update multiple records in single transactions where possible
  • Cache public keys: Don't fetch from SendGrid API on every request

Reliability

  • Implement idempotency: Store sg_event_id before processing to detect duplicates
  • Handle duplicate webhooks: SendGrid retries may deliver same event multiple times
  • Don't rely solely on webhooks: Use Email Activity API for reconciliation and backfill
  • Log all events: Comprehensive logging enables debugging and audit trails
  • Implement dead letter queues: Failed events should be stored for manual review
  • Monitor delivery gaps: Alert if no webhooks received for X minutes (possible endpoint issue)
  • Test failure scenarios: Simulate timeouts, errors, malformed payloads

Monitoring

  • Track webhook delivery success rate: Monitor 2xx vs 4xx/5xx responses in SendGrid logs
  • Alert on signature verification failures: Sudden increase may indicate attack or configuration issue
  • Monitor processing queue depth: Growing queue indicates processing bottleneck
  • Log unique event IDs: Use sg_event_id for tracing events through your system
  • Set up health checks: Ensure webhook endpoint is reachable and responsive
  • Track event type distribution: Sudden changes (e.g., spike in bounces) warrant investigation
  • Compare with Email Activity API: Periodic reconciliation ensures no missed webhooks

SendGrid-Specific Best Practices

Event Ordering:

  • SendGrid does not guarantee webhook delivery order
  • Use timestamp field to order events in your processing logic
  • Handle out-of-order events gracefully (e.g., receiving "open" before "delivered")

Webhook Retry Behavior:

  • SendGrid retries failed deliveries (non-2xx responses) with exponential backoff
  • Retry window: up to 24 hours
  • After 24 hours, failed webhooks are not resent
  • Check webhook delivery logs in dashboard to monitor retry attempts

Rate Limits:

  • No explicit rate limits on webhook deliveries
  • Events sent as they occur (real-time)
  • Batch size varies (typically 1-100 events per POST)
  • Design for burst traffic (e.g., broadcast email campaigns triggering thousands of opens)

Categories and Unique Arguments:

  • SendGrid marks categories and unique_args as "Not PII" fields
  • Do not include personally identifiable information in these fields
  • Use for tracking campaigns, not user data

Testing vs Production:

  • Use separate webhooks for test/production environments
  • Different public keys per webhook configuration
  • Test mode doesn't send webhooks—use real sends in development environment

Common Issues & Troubleshooting

Issue 1: Signature Verification Failing

Symptoms:

  • 403 errors in SendGrid webhook delivery logs
  • "Invalid signature" errors in your application logs
  • Webhook events not processing despite SendGrid sending them

Causes & Solutions:

Using wrong public key: Test vs production keys are different

Solution: Verify the public key in your environment variable matches the one shown in SendGrid dashboard for this specific webhook. Copy-paste carefully to avoid whitespace issues.

# Check your environment variable
echo $SENDGRID_WEBHOOK_PUBLIC_KEY

# Should match exactly what's shown in SendGrid Settings > Event Webhooks

Parsing JSON before verification: Body modified, signature won't match

Solution: Use raw body parser for webhook endpoint, verify signature before calling JSON.parse(). See code examples in Signature Verification section.

// Wrong: JSON parsed before verification
app.use(express.json()); // Applied to ALL routes
app.post('/webhooks/sendgrid', (req, res) => {
  const signature = req.get('X-Twilio-Email-Event-Webhook-Signature');
  const payload = JSON.stringify(req.body); // Body already parsed!
  // Signature verification will fail
});

// Correct: Raw body for webhook endpoint only
app.use('/webhooks/sendgrid', express.raw({type: 'application/json'}));
app.post('/webhooks/sendgrid', (req, res) => {
  const payload = req.body; // Raw Buffer
  // Verify signature with raw bytes
});

Incorrect algorithm or library version: SendGrid uses ECDSA, not HMAC

Solution: Use SendGrid's official libraries (@sendgrid/eventwebhook, sendgrid-python, etc.) which implement correct ECDSA verification. Don't attempt manual crypto operations.


Whitespace trimming or encoding issues: Raw body must be exact bytes

Solution: Preserve raw request body exactly as received. No .trim(), no encoding conversions.

Issue 2: Webhook Timeouts

Symptoms:

  • SendGrid delivery logs show timeout errors
  • Events eventually delivered after multiple retries
  • Increasing queue depth in your processing system

Causes & Solutions:

Slow database queries: Synchronous DB operations blocking response

Solution: Move all database operations to async queue. Return 204 within milliseconds, not seconds.

// Wrong: Blocking response on database write
app.post('/webhooks/sendgrid', async (req, res) => {
  const events = JSON.parse(req.body.toString());

  for (const event of events) {
    await database.insert(event); // Blocking!
  }

  res.status(204).send(); // May timeout if DB slow
});

// Correct: Queue immediately, respond fast
app.post('/webhooks/sendgrid', async (req, res) => {
  const events = JSON.parse(req.body.toString());

  await queue.add(events); // Fast in-memory operation
  res.status(204).send(); // Responds in <10ms

  // Database writes happen in background worker
});

External API calls: Waiting for third-party services

Solution: Never make external API calls in webhook handler. Queue events and call external APIs asynchronously.


Complex business logic: Processing takes too long

Solution: Acknowledge receipt immediately (204), process business logic in background jobs.

Issue 3: Duplicate Events

Symptoms:

  • Same sg_event_id processed multiple times
  • Duplicate database entries for single email event
  • Data inconsistencies (e.g., double-counting opens)

Causes & Solutions:

No idempotency check: Processing every webhook without checking for duplicates

Solution: Store sg_event_id in database before processing. Check for existence before executing business logic.

async function processEvent(event) {
  const eventId = event.sg_event_id;

  // Check if already processed
  const exists = await db.webhookEvents.findUnique({
    where: { eventId }
  });

  if (exists) {
    console.log(`Event ${eventId} already processed, skipping`);
    return;
  }

  // Process event
  await db.webhookEvents.create({
    data: { eventId, status: 'processing', ... }
  });

  // Execute business logic
  await handleEvent(event);
}

Network retries: SendGrid retries if your endpoint times out or returns error

Solution: Return 200/204 for successfully received webhooks (even if background processing later fails). Only return 5xx if webhook couldn't be queued.

Issue 4: Missing Webhooks

Symptoms:

  • Expected webhooks not arriving
  • SendGrid delivery logs show success but endpoint not hit
  • Gaps in event data despite emails being sent

Causes & Solutions:

Firewall blocking: Corporate firewall blocking inbound HTTPS from SendGrid IPs

Solution: Check firewall rules. SendGrid doesn't publish IP ranges, but monitor logs for SendGrid source IPs and allowlist them. Verify with curl from external server:

# Test from external server (not your network)
curl -X POST https://yourdomain.com/webhooks/sendgrid \
  -H "Content-Type: application/json" \
  -d '[{"event":"test"}]'

Wrong webhook URL: Typo in SendGrid configuration

Solution: Verify exact URL in SendGrid dashboard. Check for:

  • HTTPS vs HTTP
  • Trailing slashes
  • Correct subdomain/path

SSL certificate issues: Expired or invalid certificate on your server

Solution: Verify SSL certificate with:

curl -vI https://yourdomain.com/webhooks/sendgrid

# Check expiration
openssl s_client -connect yourdomain.com:443 -servername yourdomain.com

Webhook disabled: Accidentally toggled off in dashboard

Solution: Navigate to SendGrid webhook settings and verify "Enabled" toggle is ON.

Issue 5: Events Arriving Out of Order

Symptoms:

  • Receiving "open" event before "delivered" event
  • Timestamp in later event is earlier than previous event
  • Processing logic assumes event order

Cause:

SendGrid does not guarantee webhook delivery order. Network latency, server load, and retry logic can cause events to arrive out of sequence.

Solution:

Design your processing logic to be order-independent:

Use timestamps: Sort events by timestamp field in your processing logic

async function processEvents(events) {
  // Sort by timestamp before processing
  const sorted = events.sort((a, b) => a.timestamp - b.timestamp);

  for (const event of sorted) {
    await handleEvent(event);
  }
}

State machines: Use status fields that can handle updates regardless of order

// Instead of simple progression (sent -> delivered -> opened)
// Use status that can be updated in any order
const STATUS_HIERARCHY = {
  'sent': 1,
  'delivered': 2,
  'opened': 3,
  'clicked': 4
};

async function updateEmailStatus(messageId, newStatus) {
  const current = await db.emailLog.findUnique({ where: { messageId } });

  // Only update if new status is "higher" than current
  if (STATUS_HIERARCHY[newStatus] > STATUS_HIERARCHY[current.status]) {
    await db.emailLog.update({
      where: { messageId },
      data: { status: newStatus }
    });
  }
}

Debugging Checklist

Use this checklist to diagnose webhook issues:

  • Check SendGrid webhook delivery logs (Settings > Event Webhooks > View Logs)
  • Verify webhook endpoint is publicly accessible (test with curl from external server)
  • Test signature verification with Webhook Payload Generator
  • Check application logs for errors during webhook processing
  • Verify SSL certificate is valid (not expired, correct domain)
  • Confirm public key matches SendGrid dashboard configuration
  • Test with SendGrid's "Test Integration" button in dashboard
  • Check rate limiting isn't blocking legitimate requests
  • Monitor queue depth if using async processing
  • Compare Email Activity API data with received webhooks (reconciliation)

If issues persist after checking all items, use our Webhook Payload Generator to create test payloads with known-good signatures to isolate whether the issue is with SendGrid's delivery or your processing logic.

Frequently Asked Questions

Q: How often does SendGrid send webhooks?

A: SendGrid sends webhooks immediately when events occur, typically within seconds of the event. Events are batched for efficiency—you may receive 1-100 events in a single POST depending on email volume. If delivery fails, SendGrid retries with exponential backoff for up to 24 hours.

Q: Can I receive webhooks for past events?

A: No, SendGrid webhooks are real-time only. Historical events are not sent via webhooks. If you need past event data, use the Email Activity API to query events from the last 7 days (free tier) or 30 days (paid tier). For long-term event storage, process webhooks into your own database.

Q: What happens if my endpoint is down?

A: SendGrid will retry failed webhook deliveries (non-2xx status codes or timeouts) with exponential backoff for up to 24 hours. After the retry window expires, events are not resent. You can check delivery status in SendGrid's webhook logs. Implement the Email Activity API as a backup to query missed events during downtime.

Q: Do I need different endpoints for test and production?

A: Yes, it's best practice to use separate webhook URLs and public keys for test/sandbox and production environments. This isolates test traffic, prevents accidental production data in development, and allows different processing logic. Configure separate webhooks in SendGrid dashboard, each with its own endpoint and signature key.

Q: How do I handle webhook ordering?

A: SendGrid does not guarantee event delivery order. Design your processing to be order-independent by using the timestamp field to sort events. Use state machines or status hierarchies that can handle updates arriving in any sequence (e.g., receiving "open" before "delivered").

Q: Can I filter which events I receive?

A: Yes, when setting up your webhook in the SendGrid dashboard, select only the event types you need under "Actions to be posted." This reduces payload size, minimizes processing overhead, and improves performance. You can update event subscriptions at any time without disrupting existing webhook delivery.

Q: What's the difference between sg_event_id and sg_message_id?

A: sg_event_id uniquely identifies each webhook event (use this for idempotency checking). sg_message_id identifies the original email message—multiple events (delivered, open, click) for the same email share the same sg_message_id. Use sg_message_id to group related events and link to Email Activity API data.

Q: How do I test webhooks without sending real emails?

A: Use our Webhook Payload Generator to create properly signed test payloads with customizable event data. This lets you test signature verification, event handling, and error scenarios without SendGrid account setup, ngrok tunneling, or sending actual emails. For testing with real SendGrid events, use ngrok to expose your localhost.

Q: Why am I getting signature verification failures?

A: Common causes include: (1) Parsing JSON before verification—use raw body parser, (2) Using wrong public key—verify it matches SendGrid dashboard, (3) Whitespace/encoding changes—preserve exact raw bytes, (4) Using incorrect library—use SendGrid's official ECDSA libraries. See "Common Issues & Troubleshooting" section for detailed solutions.

Q: What's ECDSA and why does SendGrid use it?

A: ECDSA (Elliptic Curve Digital Signature Algorithm) is a public-key cryptography method that provides strong security with smaller key sizes than RSA. SendGrid generates a private key (kept secret) to sign webhook requests and provides you with a public key to verify signatures. This proves webhooks genuinely originated from SendGrid, preventing spoofing attacks. ECDSA is more secure than HMAC-SHA256 used by some other webhook providers.

Next Steps & Resources

Try It Yourself

Ready to implement SendGrid webhooks? Follow these steps:

  1. Set up your webhook in SendGrid dashboard following the Setting Up SendGrid Webhooks section
  2. Test with our tool: Visit the Webhook Payload Generator to create signed test payloads
  3. Implement signature verification using code examples from this guide (Node.js, Python, or PHP)
  4. Deploy to production with async processing, idempotency checks, and monitoring

Additional Resources

SendGrid Official Documentation:

SendGrid Official Libraries:

Related Guides on InventiveHQ:

Testing & Development Tools:

Need Help?

Conclusion

SendGrid Event Webhooks provide a robust, real-time email tracking solution that powers email analytics, automated workflows, and deliverability monitoring for thousands of applications. By following this guide, you now know how to:

  • ✅ Set up SendGrid webhooks in your account with proper event subscriptions
  • ✅ Verify webhook signatures securely using ECDSA public-key cryptography
  • ✅ Implement production-ready webhook endpoints with async processing
  • ✅ Handle all 12+ event types from delivery to engagement to reputation
  • ✅ Test webhooks effectively using ngrok or our payload generator tool
  • ✅ Troubleshoot common issues like signature failures and duplicate events

Remember these key principles:

  1. Always verify signatures - ECDSA verification prevents spoofing and ensures request authenticity
  2. Respond quickly - Return 204 within 10 seconds to avoid timeouts and retries
  3. Process asynchronously - Queue events for background processing for reliability at scale
  4. Implement idempotency - Use sg_event_id to detect and handle duplicate events gracefully

SendGrid's webhook system combines enterprise-grade security (ECDSA signatures), reliability (24-hour retry window), and flexibility (12+ event types, custom arguments) to give you complete visibility into email lifecycles. Whether you're tracking transactional email delivery, measuring marketing campaign engagement, or maintaining compliance with unsubscribe requests, webhooks provide the real-time data pipeline your application needs.

Start building with SendGrid webhooks today, and use our Webhook Payload Generator to test your integration without sending real emails or exposing your development environment.

Have questions or run into issues? Visit the SendGrid Community Forums or contact our team for webhook integration assistance.


Sources:

Need Expert IT & Security Guidance?

Our team is ready to help protect and optimize your business technology infrastructure.