Skip to content

Mailgun returns 401 and your API key is fine: the EU/US region trap

A Mailgun domain lives in the region it was created in. Calling the wrong regional host returns 401 Unauthorized, which reads exactly like a bad API key.

Mailgun4 min readships at docs/solutions/mailgun/eu-vs-us-region-endpoints.md

Tags: mailgun · email · regions · eu · gdpr · api

You copy your Mailgun private API key into .env.local, send a test message, and get:

Error: Unauthorized
    status: 401

So you regenerate the key. Same error. You check for a trailing newline, paste it into curl, try the key from a different machine. Still 401. The key works in the dashboard, the domain shows Active, and every request is rejected.

The key is fine. You are asking the wrong data centre about a domain it has never heard of.

Mailgun is two independent regions

Mailgun runs a US region and an EU region, and they are not two views of one account: they are separate stacks with separate storage, separate domain lists and separate API hosts:

  • US: https://api.mailgun.net
  • EU: https://api.eu.mailgun.net

A domain is created in exactly one of them, and the region cannot be changed afterwards. There is no migration and no setting. Moving a domain to the other region means deleting it and creating it again, with new DNS records and a reputation that starts from zero.

Your API key authenticates you against a region. Present a US key to the EU host and the EU host has no record of that credential, so it answers 401 Unauthorized. It does not answer 404 Not Found, which is what you would want, because "which domains exist" is exactly the information a 404 would leak to anyone probing with a stolen key. The security-correct answer is also the maximally confusing one.

The wrong fix

Almost everyone tries these first, and none of them can work:

// Regenerating the key. It was never the problem.
// Adding the domain again in the dashboard, while the dashboard is still
// showing the *other* region, so now you have two domains.
// Switching to SMTP, which fails the same way with a different message.
const mg = new Mailgun(formData).client({ username: "api", key: process.env.MAILGUN_API_KEY! });

That last line is the actual bug: mailgun.js defaults to the US host when you do not pass url. If your domain is in the EU, every call this client makes is addressed to a stack that does not know you exist.

The dashboard hides the problem too. It has a region selector, and it remembers your last choice, so the domain you are looking at while you debug may not be the one your code is calling.

The fix

Make the region an explicit, checked configuration value, not a default.

// src/lib/email/mailgun.ts
export type MailgunRegion = "us" | "eu";

export function region(): MailgunRegion {
  return optionalEnv("MAILGUN_REGION") === "eu" ? "eu" : "us";
}

/**
 * The host is decided by the region the domain was created in, and a domain's
 * region cannot be changed afterwards. Getting this wrong produces a 401, not
 * a 404: the error reads exactly like a bad API key.
 */
export function apiUrl(): string {
  return region() === "eu"
    ? "https://api.eu.mailgun.net"
    : "https://api.mailgun.net";
}

let instance: MailgunClient | null = null;

/**
 * Built on first use, not at module load: constructing it eagerly would read
 * MAILGUN_API_KEY during `next build`, where CI has no production secrets.
 */
export function client(): MailgunClient {
  if (instance === null) {
    instance = new Mailgun(formData).client({
      username: "api",
      key: env("MAILGUN_API_KEY"),
      url: apiUrl(),
    });
  }
  return instance;
}

With MAILGUN_REGION in the environment, the region is visible in .env.example and in every deployment's settings, and a new engineer can see it without reading the SDK's defaults.

Make the 401 say what it means

The real cost of this bug is the hour spent on the wrong hypothesis. Catch the status and name both possibilities in the error, in the one place that checks configuration:

// The verify check, which calls the REST API with fetch rather than the SDK:
// `verify` runs as a plain script, and every module under src/lib/email starts
// with `import "server-only"`, which throws outside Next.js.
const response = await fetch(`${host}/v3/domains/${domain}`, {
  headers: { Authorization: `Basic ${auth}` },
});

if (response.status === 401) {
  throw new Error(
    `401 from the ${region} host. Either MAILGUN_API_KEY is wrong or the ` +
      "domain lives in the other region: set MAILGUN_REGION.",
  );
}

To settle it in ten seconds from a terminal, ask both hosts:

curl -s -o /dev/null -w "us  %{http_code}\n" --user "api:$MAILGUN_API_KEY" \
  https://api.mailgun.net/v3/domains

curl -s -o /dev/null -w "eu  %{http_code}\n" --user "api:$MAILGUN_API_KEY" \
  https://api.eu.mailgun.net/v3/domains

A 200 from one and a 401 from the other tells you which region your key belongs to. A 401 from both means the key really is wrong.

Choosing a region on purpose

Do this before you create the domain, because afterwards it is a rebuild.

Pick EU when personal data must stay in the EU. This is not a formality: Mailgun stores message content, recipient addresses and event logs in the region the domain lives in, and a message body is often full of personal data. If your data processing agreement or your customers' procurement questionnaires say European storage, an EU domain is the only way to honour that: an EU-facing company on a US domain is quietly shipping every recipient address across the Atlantic.

Pick US otherwise. It is the default, the latency is lower for US recipients, and some newer features land there first.

Then be consistent everywhere:

  • The dashboard region selector, when you create the domain and when you read logs. A log search in the wrong region returns nothing, which looks like a message that was never sent.
  • MAILGUN_REGION in .env.local, in preview and in production.
  • The webhook signing key: it is also per-region, and copying it from the wrong region's Settings page gives you a key that fails every signature check.
  • Any other tool that talks to Mailgun: SMTP hosts differ too (smtp.mailgun.org versus smtp.eu.mailgun.org).

The one-line summary

If Mailgun returns 401 and you are certain the key is right, you are almost certainly certain about the wrong region. Check MAILGUN_REGION before you touch the key.