Setting Up Webhooks to Trigger Automated Emails in SendGrid

Setting Up Webhooks to Trigger Automated Emails in SendGrid

The Problem

Most applications need to send transactional emails — a user signs up, a payment succeeds, a password resets — but wiring that up cleanly is harder than it looks. Sending email directly from your app on every event creates tight coupling, blocks your request cycle, and breaks silently when SendGrid is unreachable. The better architecture is a webhook-driven pipeline: your app fires an event to an endpoint, that endpoint calls the SendGrid API, and the email goes out asynchronously. The problem is that most tutorials skip the hard parts — webhook signature verification, idempotency, payload validation, and what happens when SendGrid returns a non-200. Getting this wrong means missed emails, duplicate sends, or an open endpoint that anyone can abuse. This tutorial builds the full pipeline correctly from the first line of code.

Tech Stack & Prerequisites

  • Node.js v20+ with Express 4.18+
  • SendGrid account — free tier sends 100 emails/day (signup)
  • SendGrid API Key — with Mail Send permission (Full Access)
  • A verified Sender Identity in SendGrid (domain or single sender)
  • @sendgrid/mail 8.1.x — official SendGrid Node.js SDK
  • dotenv 16.4.x — environment variable management
  • ngrok — to expose localhost for webhook testing
  • Postman or cURL — to fire test webhook events
  • npm 10+

Step-by-Step Implementation

Step 1: Setup

Initialize the project and install all dependencies.

bash
mkdir sendgrid-webhook && cd sendgrid-webhook
npm init -y
npm install express@4.18.3 @sendgrid/mail@8.1.0 dotenv@16.4.5
```

Create the following project structure:
```
sendgrid-webhook/
├── .env
├── .gitignore
├── server.js
├── mailer.js
└── routes/
    └── webhook.js

Add .gitignore immediately before writing any credentials:

bash
echo ".env\nnode_modules/" >> .gitignore

Step 2: Configuration

.env

env
PORT=3000
SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
FROM_EMAIL=hello@yourdomain.com
FROM_NAME=Your App Name
WEBHOOK_SECRET=your_random_secret_string_min_32_chars

WEBHOOK_SECRET is a shared secret you define. Any service sending webhooks to your endpoint must include it. Generate one with:

bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Load config at the application entry point — never call process.env directly in business logic files:

config.js

js
import 'dotenv/config';

// Fail fast — crash on startup if any required variable is missing
const required = ['SENDGRID_API_KEY', 'FROM_EMAIL', 'WEBHOOK_SECRET'];
for (const key of required) {
  if (!process.env[key]) {
    throw new Error(`Missing required environment variable: ${key}`);
  }
}

export const config = {
  port:           process.env.PORT || 3000,
  sendgridApiKey: process.env.SENDGRID_API_KEY,
  fromEmail:      process.env.FROM_EMAIL,
  fromName:       process.env.FROM_NAME || 'App',
  webhookSecret:  process.env.WEBHOOK_SECRET,
};

Step 3: Core Logic

3a — SendGrid Mailer Module

mailer.js

js
import sgMail from '@sendgrid/mail';
import { config } from './config.js';

// Initialize the SDK once at module load
sgMail.setApiKey(config.sendgridApiKey);

/**
 * Send a transactional email via SendGrid.
 * @param {Object} options
 * @param {string} options.to         - Recipient email address
 * @param {string} options.subject    - Email subject line
 * @param {string} options.text       - Plain-text fallback body
 * @param {string} options.html       - HTML body
 * @returns {Promise<void>}
 */
export async function sendEmail({ to, subject, text, html }) {
  const msg = {
    to,
    from: {
      email: config.fromEmail,
      name:  config.fromName,
    },
    subject,
    text,
    html,
  };

  try {
    await sgMail.send(msg);
    console.log(`[mailer] Email sent to ${to} — subject: "${subject}"`);
  } catch (error) {
    // SendGrid wraps error details in error.response.body
    const detail = error.response?.body?.errors ?? error.message;
    console.error('[mailer] SendGrid error:', JSON.stringify(detail, null, 2));
    throw error; // re-throw so the caller can handle HTTP response
  }
}

3b — Webhook Route with Signature Verification

routes/webhook.js

js
import { Router }   from 'express';
import crypto       from 'crypto';
import { config }   from '../config.js';
import { sendEmail } from '../mailer.js';

const router = Router();

/**
 * Verify the incoming webhook is from a trusted source.
 * The sender must include: Authorization: Bearer <WEBHOOK_SECRET>
 */
function verifyWebhookSecret(req, res, next) {
  const authHeader = req.headers['authorization'] ?? '';
  const token      = authHeader.replace('Bearer ', '').trim();

  // Constant-time comparison prevents timing attacks
  const expected = Buffer.from(config.webhookSecret);
  const received = Buffer.from(token);

  if (
    expected.length !== received.length ||
    !crypto.timingSafeEqual(expected, received)
  ) {
    console.warn('[webhook] Unauthorized request — invalid secret');
    return res.status(401).json({ error: 'Unauthorized' });
  }

  next();
}

/**
 * Map incoming event types to email templates.
 * Extend this object to handle new event types.
 */
const EVENT_TEMPLATES = {
  'user.signup': (payload) => ({
    to:      payload.email,
    subject: `Welcome to ${config.fromName}, ${payload.name}!`,
    text:    `Hi ${payload.name}, your account is ready.`,
    html:    `<h1>Welcome, ${payload.name}!</h1><p>Your account is live. <a href="${payload.dashboardUrl}">Go to dashboard →</a></p>`,
  }),

  'payment.success': (payload) => ({
    to:      payload.email,
    subject: `Payment confirmed — $${payload.amount}`,
    text:    `Your payment of $${payload.amount} was successful. Receipt ID: ${payload.receiptId}`,
    html:    `<h2>Payment confirmed ✓</h2><p>Amount: <strong>$${payload.amount}</strong><br/>Receipt: ${payload.receiptId}</p>`,
  }),

  'password.reset': (payload) => ({
    to:      payload.email,
    subject: 'Reset your password',
    text:    `Click the link to reset your password: ${payload.resetUrl}`,
    html:    `<p>Click below to reset your password (expires in 30 min):</p><a href="${payload.resetUrl}">${payload.resetUrl}</a>`,
  }),
};

/**
 * POST /webhook/email
 * Accepts an event type + payload and triggers the matching email.
 *
 * Expected body:
 * {
 *   "event": "user.signup",
 *   "payload": { "email": "...", "name": "...", "dashboardUrl": "..." }
 * }
 */
router.post('/email', verifyWebhookSecret, async (req, res) => {
  const { event, payload } = req.body;

  // Validate required fields
  if (!event || !payload) {
    return res.status(400).json({ error: 'Missing "event" or "payload"' });
  }

  // Look up the template builder for this event
  const buildTemplate = EVENT_TEMPLATES[event];
  if (!buildTemplate) {
    return res.status(422).json({ error: `Unknown event type: "${event}"` });
  }

  // Validate that recipient email is present in payload
  if (!payload.email) {
    return res.status(400).json({ error: 'payload.email is required' });
  }

  try {
    const emailOptions = buildTemplate(payload);
    await sendEmail(emailOptions);
    return res.status(200).json({ success: true, event });
  } catch (err) {
    // Return 500 so the caller can retry
    return res.status(500).json({ error: 'Email delivery failed', detail: err.message });
  }
});

export default router;

3c — Express Server Entry Point

server.js

js
import express      from 'express';
import { config }   from './config.js';
import webhookRouter from './routes/webhook.js';

const app = express();

// Parse JSON bodies — must come before routes
app.use(express.json());

// Health check — useful for uptime monitors and load balancers
app.get('/health', (_req, res) => res.json({ status: 'ok' }));

// Mount webhook routes
app.use('/webhook', webhookRouter);

app.listen(config.port, () => {
  console.log(`[server] Running on http://localhost:${config.port}`);
});

Add "type": "module" to package.json to enable ES module imports:

json
{
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js"
  }
}

Start the server:

bash
npm run dev

Step 4: Testing

4a — Expose localhost with ngrok

bash
ngrok http 3000
```

ngrok will output a public URL like:
```
Forwarding  https://a1b2-123-456.ngrok-free.app → http://localhost:3000

Use this URL as your webhook endpoint in any external service.

4b — Fire a test webhook with cURL

bash
curl -X POST http://localhost:3000/webhook/email \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your_random_secret_string_min_32_chars" \
  -d '{
    "event": "user.signup",
    "payload": {
      "email": "test@example.com",
      "name": "Jamie",
      "dashboardUrl": "https://yourapp.com/dashboard"
    }
  }'

Expected response:

json
{
  "success": true,
  "event": "user.signup"
}
```

---

#### 4c — Verify delivery in SendGrid

1. Log in to **SendGrid → Activity Feed**
2. Filter by the recipient email address
3. You should see status **Delivered** within a few seconds

Check your terminal for the log line:
```
[mailer] Email sent to test@example.com — subject: "Welcome to App, Jamie!"

4d — Test all event types

bash
# payment.success
curl -X POST http://localhost:3000/webhook/email \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your_random_secret_string_min_32_chars" \
  -d '{
    "event": "payment.success",
    "payload": {
      "email": "test@example.com",
      "amount": "49.00",
      "receiptId": "rcpt_abc123"
    }
  }'

# password.reset
curl -X POST http://localhost:3000/webhook/email \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your_random_secret_string_min_32_chars" \
  -d '{
    "event": "password.reset",
    "payload": {
      "email": "test@example.com",
      "resetUrl": "https://yourapp.com/reset?token=xyz789"
    }
  }'

Common Errors & Troubleshooting

Gotcha 1 — 403 Forbidden from SendGrid: "The from address does not match a verified Sender Identity"

SendGrid requires your FROM_EMAIL to be verified before it will send. Unverified senders are silently blocked.

Fix:

  • Go to SendGrid → Settings → Sender Authentication
  • Either verify a Single Sender (fastest, use your email address) or authenticate an entire domain (recommended for production)
  • Confirm the email in FROM_EMAIL in your .env exactly matches the verified sender — including subdomain

Gotcha 2 — 401 Unauthorized even though the secret looks correct

This almost always means there is whitespace, a newline character, or quote marks embedded in the secret when it was copied into .env.

Fix: Log and inspect the raw header value during debugging:

js
// Temporarily add this to verifyWebhookSecret for debugging only
console.log('Received token bytes:', Buffer.from(token).toJSON().data);
console.log('Expected token bytes:', Buffer.from(config.webhookSecret).toJSON().data);

Compare the byte arrays. If they differ in length due to a trailing \n or space, trim your .env value. Remove this debug log before deploying.

Gotcha 3 — Emails send successfully but land in spam

200 OK from SendGrid means accepted for delivery — not that it reached the inbox. Spam classification happens downstream.

Fix (in order of impact):

bash
# 1. Authenticate your domain (SPF, DKIM, DMARC)
#    → SendGrid → Settings → Sender Authentication → Domain Authentication

# 2. Always send both text and html body — spam filters penalize HTML-only emails
#    (Already handled in mailer.js — never remove the `text` field)

# 3. Avoid spam trigger words in subject lines:
#    ❌ "FREE", "Act Now", "Guaranteed", "!!!"
#    ✅ "Your receipt from App", "Welcome to App"

# 4. Check your sending IP reputation
#    → https://senderscore.org
#    → https://mxtoolbox.com/blacklists.aspx

For production volume, enable SendGrid’s Dedicated IP and warm it up gradually.

Security Checklist

  • Never log the raw WEBHOOK_SECRET or SENDGRID_API_KEY — not in console, not in error messages, not in response bodies
  • Always use crypto.timingSafeEqual for secret comparison — standard === is vulnerable to timing attacks
  • Scope your SendGrid API key to Mail Send only — do not create Full Access keys for this integration
  • Rotate the WEBHOOK_SECRET if it is ever exposed — update it in your .env and in the calling service simultaneously
  • Rate-limit your webhook endpoint in production using a middleware like express-rate-limit:
bash
npm install express-rate-limit
js
import rateLimit from 'express-rate-limit';
app.use('/webhook', rateLimit({ windowMs: 60_000, max: 60 }));
  • Validate payload.email format before sending — use a regex or email-validator to prevent SendGrid delivery errors from malformed addresses
  • Store secrets in a secrets manager for production — AWS Secrets Manager, Doppler, or GitHub Actions Secrets — never a .env file on a server
  • Log delivery failures to an external service (Datadog, Sentry) so missed emails are always visible and alertable

Leave a Comment

Your email address will not be published. Required fields are marked *