Mailgun webhooks and CORS get mixed up all the time, mostly because they solve different problems.

Here’s the blunt version:

  • Mailgun sending a webhook to your server does not need CORS
  • Your browser calling your webhook endpoint does need CORS
  • Your frontend should usually not call Mailgun directly

That’s the whole mental model. If you keep those three rules straight, most confusion disappears.

The short answer

If Mailgun sends an event like delivered, opened, or failed to your backend:

Mailgun -> your server

CORS is irrelevant because this is server-to-server HTTP, not a browser-enforced cross-origin request.

If your frontend dashboard does this:

fetch("https://api.example.com/mailgun/webhooks/events")

from a different origin like:

https://app.example.com

then CORS applies, because the browser is involved.

When CORS matters for Mailgun setups

These are the common cases.

Case 1: Mailgun posts webhook events to your backend

No browser. No CORS.

Example webhook target:

POST https://api.example.com/webhooks/mailgun

Mailgun sends the request directly. Your server just needs to:

  • accept the request
  • verify Mailgun’s signature
  • return a 2xx response quickly

You do not need Access-Control-Allow-Origin for Mailgun itself.

Case 2: Your frontend reads webhook data from your backend

Browser involved. CORS matters.

Typical setup:

  • Frontend: https://dashboard.example.com
  • API: https://api.example.com

Your frontend fetches processed Mailgun event data from your API:

const res = await fetch("https://api.example.com/mailgun/events", {
  credentials: "include",
});
const data = await res.json();
console.log(data);

Now your API must send CORS headers that allow https://dashboard.example.com.

Case 3: You try to call Mailgun APIs directly from the browser

Usually a bad idea.

Why:

  • You’d expose credentials or create ugly token flows
  • Mailgun APIs are not designed as a browser-facing public API
  • You’ll run into CORS and auth issues you don’t need

Use your backend as a proxy or integration layer.

The headers you actually need

For browser access to your own Mailgun-related endpoints, the usual CORS headers are:

Access-Control-Allow-Origin: https://dashboard.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Vary: Origin

If you don’t use cookies or HTTP auth, you may not need Access-Control-Allow-Credentials.

If your API is intentionally public and doesn’t use credentials, you can sometimes use:

Access-Control-Allow-Origin: *

That’s what some public APIs do. For example, api.github.com returns:

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

That works for a public API because it’s not relying on browser cookies for auth. Your internal Mailgun event API probably should not be that open.

The most common mistake

People add CORS headers to the Mailgun webhook endpoint and expect that to fix browser fetch errors elsewhere.

It won’t.

This endpoint:

POST /webhooks/mailgun

is for Mailgun to call.

This endpoint:

GET /mailgun/events

is for your frontend to call.

Treat them as separate endpoints with separate concerns.

My usual recommendation:

  • Keep the raw Mailgun webhook route private and boring
  • Expose a separate frontend-friendly route for your app
  • Put CORS only on the routes the browser actually hits

Express example

Here’s a practical Node/Express setup.

1. Mailgun webhook endpoint without CORS logic

import express from "express";

const app = express();

app.use(express.json());

app.post("/webhooks/mailgun", (req, res) => {
  // Verify Mailgun signature here
  // Store event in DB / queue
  console.log("Mailgun webhook:", req.body);

  res.status(200).send("ok");
});

That’s enough for the webhook transport itself.

2. Browser-facing API with CORS

import express from "express";
import cors from "cors";

const app = express();

app.use(express.json());

app.use(
  "/mailgun",
  cors({
    origin: "https://dashboard.example.com",
    credentials: true,
    methods: ["GET", "POST", "OPTIONS"],
    allowedHeaders: ["Content-Type", "Authorization"],
    exposedHeaders: ["ETag", "Link"],
  })
);

app.get("/mailgun/events", (req, res) => {
  res.json([
    { id: "evt_1", event: "delivered", recipient: "[email protected]" },
    { id: "evt_2", event: "failed", recipient: "[email protected]" },
  ]);
});

app.listen(3000);

That gives your frontend access while keeping the webhook route separate.

Manual CORS example without middleware

Sometimes I skip middleware for one endpoint because it’s easier to reason about.

import express from "express";

const app = express();

app.options("/mailgun/events", (req, res) => {
  res.setHeader("Access-Control-Allow-Origin", "https://dashboard.example.com");
  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("Vary", "Origin");
  res.sendStatus(204);
});

app.get("/mailgun/events", (req, res) => {
  res.setHeader("Access-Control-Allow-Origin", "https://dashboard.example.com");
  res.setHeader("Access-Control-Allow-Credentials", "true");
  res.setHeader("Vary", "Origin");

  res.json({ ok: true });
});

app.listen(3000);

This is nice when you want zero magic.

Credentialed requests: the rule people forget

If your frontend uses:

fetch("https://api.example.com/mailgun/events", {
  credentials: "include",
});

you cannot use:

Access-Control-Allow-Origin: *

You must return the exact origin:

Access-Control-Allow-Origin: https://dashboard.example.com
Access-Control-Allow-Credentials: true

That wildcard-plus-credentials combo is invalid in browsers.

Preflight requests for Mailgun dashboards

You’ll often see an OPTIONS request first. That’s preflight.

Typical trigger:

  • Authorization header
  • Content-Type: application/json on some cross-origin requests
  • non-simple methods like PUT or DELETE

Example browser request:

await fetch("https://api.example.com/mailgun/events/search", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: "Bearer token123",
  },
  body: JSON.stringify({ recipient: "[email protected]" }),
});

Your server needs to answer the preflight cleanly:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://dashboard.example.com
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Vary: Origin

If preflight fails, the browser blocks the actual request even if your backend route works fine in Postman or curl.

Nginx example

If you terminate traffic at Nginx, you can set CORS there.

location /mailgun/events {
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin https://dashboard.example.com always;
        add_header Access-Control-Allow-Credentials true always;
        add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
        add_header Vary Origin always;
        return 204;
    }

    add_header Access-Control-Allow-Origin https://dashboard.example.com always;
    add_header Access-Control-Allow-Credentials true always;
    add_header Vary Origin always;

    proxy_pass http://app_backend;
}

I still prefer handling CORS in the app unless there’s a strong ops reason not to. It’s easier to keep route behavior close to the code.

Exposing response headers to frontend code

Browsers only expose a limited set of response headers to JavaScript unless you opt in.

If your frontend needs to read custom headers like rate limits or pagination:

Access-Control-Expose-Headers: ETag, Link, X-RateLimit-Remaining

That’s the same idea used by public APIs like GitHub, which exposes headers such as:

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

Example:

const res = await fetch("https://api.example.com/mailgun/events");
console.log(res.headers.get("ETag"));
console.log(res.headers.get("X-RateLimit-Remaining"));

Without Access-Control-Expose-Headers, those may show up as null in browser JavaScript.

Secure defaults I actually recommend

For Mailgun-related apps, this is what I’d ship first:

  • Webhook ingest route: no CORS config
  • Frontend API route: allow only your dashboard origin
  • Use credentials only if you really need cookies
  • Add Vary: Origin when origin can differ
  • Keep Access-Control-Allow-Headers small
  • Don’t use * unless the endpoint is truly public

A decent baseline:

Access-Control-Allow-Origin: https://dashboard.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Expose-Headers: ETag, Link
Vary: Origin

Debug checklist

When CORS “breaks,” I check these in order:

  1. Is the browser calling this endpoint, or is Mailgun?
  2. Did I accidentally put CORS on the webhook route instead of the frontend API route?
  3. Is preflight handled for OPTIONS?
  4. Am I using credentials: "include" with Access-Control-Allow-Origin: *?
  5. Did I forget Access-Control-Allow-Headers for Authorization or Content-Type?
  6. Do I need Access-Control-Expose-Headers for frontend header access?
  7. Is a proxy or CDN stripping my headers?

That catches most issues fast.

Official docs

For webhook payloads, signing, and Mailgun endpoint behavior, use the official Mailgun documentation: https://documentation.mailgun.com/

For browser CORS behavior and header semantics, the official MDN CORS documentation is also useful: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS

If you’re tightening broader browser-side security headers alongside CORS, I’d also keep https://csp-guide.com nearby.

CORS for Mailgun webhooks is simpler than it looks: Mailgun itself doesn’t need it, your browser does. Keep those paths separate and the config stays sane.