# 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.

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:

```ts
// 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.

```ts
// 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:

```ts
// 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:

```bash
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
