If you’re debugging “CORS errors with SendGrid webhooks,” there’s a decent chance you’re solving the wrong problem.

I’ve seen teams burn hours tweaking Access-Control-Allow-Origin on webhook endpoints that were never meant to be called by a browser in the first place. SendGrid webhooks are server-to-server callbacks. CORS is a browser enforcement layer. Those are two very different worlds.

The real mess usually starts when someone tries to involve frontend JavaScript in webhook flows.

The setup

A SaaS team wanted to show email delivery events in their dashboard in near real time:

  • delivered
  • bounced
  • opened
  • clicked

They were using SendGrid Event Webhook correctly at first: SendGrid POSTed events to their backend.

Then product asked for “live updates in the browser.”

A developer took a shortcut:

  1. Expose a webhook-like endpoint
  2. Have frontend code fetch() that endpoint directly
  3. Try to sort out CORS later

That “later” turned into a production incident.

What they built first

Their frontend polled an endpoint that was shaped like a webhook receiver:

async function loadEmailEvents() {
  const res = await fetch('https://api.example.com/sendgrid/events', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      userId: window.currentUserId
    })
  });

  const data = await res.json();
  renderEvents(data);
}

And the backend looked like this:

import express from 'express';

const app = express();
app.use(express.json());

app.post('/sendgrid/events', async (req, res) => {
  // originally intended for SendGrid webhook posts
  const events = await db.emailEvents.findMany({
    where: { userId: req.body.userId }
  });

  res.json(events);
});

app.listen(3000);

Then the browser started throwing the usual error:

Response to preflight request doesn’t pass access control check

So they “fixed” it by slapping on permissive CORS:

app.use((req, res, next) => {
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  
  if (req.method === 'OPTIONS') {
    return res.sendStatus(204);
  }

  next();
});

That made the browser happy.

It also made the design worse.

Why this was broken

Three separate problems were mixed together:

1. The SendGrid webhook endpoint was not a browser API

Webhook receivers should accept requests from SendGrid servers, not random browser tabs. They don’t need CORS unless you intentionally also expose them to frontend code.

That’s the first rule I push on teams: don’t turn webhook ingestion endpoints into public browser APIs.

2. Access-Control-Allow-Origin: * was the wrong signal

Wildcard CORS is fine for some truly public resources, but not for user-scoped event data.

GitHub is a good example of where permissive CORS can make sense for public API access. Real headers from api.github.com include:

access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning

That works because GitHub has thought carefully about what’s public, what headers clients need access to, and what auth model applies.

Your SendGrid event stream tied to customer accounts is not that.

3. They skipped verification and proper separation

SendGrid webhook payloads should be verified and stored server-side. The browser should query a separate authenticated API for already-processed data.

That split matters for both security and maintainability.

What the correct architecture looked like

We split the flow into two endpoints:

  1. Webhook ingestion endpoint
    Receives POSTs from SendGrid only. No browser access needed. No CORS needed.

  2. Frontend API endpoint
    Authenticated endpoint for the dashboard. Returns stored event data. This one may need CORS if the frontend is hosted on another origin.

That one change cleared up most of the confusion.

Before: one endpoint doing two jobs

app.post('/sendgrid/events', async (req, res) => {
  const events = await db.emailEvents.findMany({
    where: { userId: req.body.userId }
  });

  res.json(events);
});

This endpoint was pretending to be both:

  • a webhook receiver
  • a browser-facing API

That’s a red flag every time.

After: clean separation

Webhook receiver

import express from 'express';

const app = express();
app.use(express.json());

app.post('/webhooks/sendgrid', async (req, res) => {
  const events = req.body;

  // Verify signature here if using signed event webhook
  // Reject if invalid

  for (const event of events) {
    await db.emailEvents.insert({
      sgMessageId: event.sg_message_id,
      email: event.email,
      eventType: event.event,
      timestamp: new Date(event.timestamp * 1000),
      metadata: JSON.stringify(event)
    });
  }

  res.sendStatus(202);
});

No CORS headers. None.

Because SendGrid is not a browser.

Browser-facing API

const allowedOrigins = new Set([
  'https://app.example.com',
  'https://staging.example.com'
]);

app.use('/api', (req, res, next) => {
  const origin = req.headers.origin;

  if (origin && allowedOrigins.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  }

  if (req.method === 'OPTIONS') {
    return res.sendStatus(204);
  }

  next();
});

app.get('/api/email-events', requireSession, async (req, res) => {
  const events = await db.emailEvents.findMany({
    where: { accountId: req.user.accountId },
    orderBy: { timestamp: 'desc' },
    take: 100
  });

  res.json(events);
});

This is where CORS belongs, if your frontend runs on a different origin.

The frontend after the fix

The browser now talks to the real app API:

async function loadEmailEvents() {
  const res = await fetch('https://api.example.com/api/email-events', {
    method: 'GET',
    credentials: 'include'
  });

  if (!res.ok) {
    throw new Error(`Failed to load events: ${res.status}`);
  }

  const data = await res.json();
  renderEvents(data);
}

That’s boring code, which is exactly what you want.

The subtle CORS bug they hit next

After the split, the dashboard still couldn’t read a pagination header they added:

const nextCursor = res.headers.get('X-Next-Cursor');

It kept returning null in the browser, even though the header was clearly present in server logs.

Classic CORS gotcha.

Browsers only expose a limited set of response headers to JavaScript unless you explicitly allow more with Access-Control-Expose-Headers.

So the fix was:

app.use('/api', (req, res, next) => {
  const origin = req.headers.origin;

  if (origin && allowedOrigins.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    res.setHeader('Access-Control-Expose-Headers', 'X-Next-Cursor');
  }

  if (req.method === 'OPTIONS') {
    return res.sendStatus(204);
  }

  next();
});

That’s one area where GitHub’s headers are a useful reference. Their access-control-expose-headers list is long because API clients often need access to metadata like ETag, Link, and rate limit headers. Same idea, smaller scale.

If you want to sanity-check your response headers from a browser perspective, headertest.com is handy.

The security cleanup that mattered more than CORS

The original team was obsessed with CORS, but the bigger risks were elsewhere:

  • no webhook signature verification
  • no origin separation
  • no auth boundary between raw events and user-facing data
  • overly broad wildcard CORS

CORS is not an auth mechanism. It doesn’t stop server-to-server abuse. It doesn’t protect your endpoint from curl, bots, or backend misuse. It only tells browsers what frontend JavaScript is allowed to read.

That’s why I get annoyed when teams treat CORS headers like a security blanket. They’re not.

For the webhook receiver, the real controls were:

  • verify SendGrid signatures
  • rate limit if appropriate
  • store and process events asynchronously
  • avoid exposing raw ingestion endpoints to browsers

For the frontend API, the real controls were:

  • session or token auth
  • account scoping
  • precise CORS allowlist
  • exposed headers only when needed

If you’re tightening broader response header policy too, that’s separate from CORS. Don’t mash everything into one middleware blob. For things like CSP and related browser defenses, csp-guide.com is a better rabbit hole.

Practical rules I’d follow every time

Don’t put CORS on SendGrid webhook endpoints by default

If the endpoint exists only for SendGrid, skip CORS entirely.

Never reuse webhook routes for frontend reads

Separate ingestion from retrieval. Different consumers, different trust boundaries.

Use explicit origins for app APIs

Prefer:

Access-Control-Allow-Origin: https://app.example.com
Vary: Origin

Over:

Access-Control-Allow-Origin: *

Especially when credentials or user data are involved.

Expose only the headers your frontend actually needs

If the browser must read X-Next-Cursor, ETag, or Link, declare that with Access-Control-Expose-Headers.

Remember what CORS actually does

It controls browser access to responses. That’s it.

The final result

After the refactor:

  • SendGrid posted events reliably to /webhooks/sendgrid
  • the backend verified and stored events
  • the dashboard fetched processed data from /api/email-events
  • CORS was limited to actual browser-facing routes
  • no wildcard origin on sensitive endpoints
  • frontend pagination headers worked because they were explicitly exposed

The funny part is the final CORS config was smaller than the original one.

That’s usually how this goes. When the architecture is right, CORS gets simpler fast. When the architecture is wrong, people keep adding headers and hoping the browser stops yelling.