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:
AuthorizationheaderContent-Type: application/jsonon some cross-origin requests- non-simple methods like
PUTorDELETE
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: Originwhen origin can differ - Keep
Access-Control-Allow-Headerssmall - 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:
- Is the browser calling this endpoint, or is Mailgun?
- Did I accidentally put CORS on the webhook route instead of the frontend API route?
- Is preflight handled for
OPTIONS? - Am I using
credentials: "include"withAccess-Control-Allow-Origin: *? - Did I forget
Access-Control-Allow-HeadersforAuthorizationorContent-Type? - Do I need
Access-Control-Expose-Headersfor frontend header access? - 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.