---
title: "Configuration"
description: "Three ways to build a client, every option, and what it refuses before a request is sent."
url: "https://nextmail.sh/docs/sdk/configuration"
area: "TypeScript"
category: "Getting started"
---

# Configuration

Three ways to build a client, every option, and what it refuses before a request is sent.

## Options

**nextmail.ts**

```
import Nextmail, { createNextmail, init, nextmail } from '@nextmail/sdk'

init({ apiKey: process.env.NEXTMAIL_API_KEY })
await nextmail.me.ping()

export const billing = createNextmail({ apiKey: process.env.BILLING_API_KEY! })

const pinned = new Nextmail({ apiKey: 'oe_live_…', baseUrl: 'https://api.nextmail.sh' })

const quick = new Nextmail('oe_live_…')
```

| Entry point | What it gives you |
| --- | --- |
| `init(options)` | Configures the shared client and returns it. `nextmail` is that client from then on, in every module, and anything you leave out is read from the environment. |
| `nextmail` | The shared client. Used before `init`, it builds itself from `NEXTMAIL_API_KEY` and `NEXTMAIL_BASE_URL` on the first call. |
| `createNextmail(options)` | A separate client with the same environment fallback, for a second key beside the shared one, or to build the instance your own module exports. `createClient` is the same function under the name the envless SDK uses. |
| `new Nextmail(options)` or `new Nextmail(apiKey)` | A separate client built from exactly what you pass. It reads no environment, so `apiKey` is required. Also the default export. |

**options.ts**

```
init({
  apiKey: 'oe_live_…',
  baseUrl: 'https://api.nextmail.sh',
  timeoutMs: 30_000,
  maxRetries: 2,
  fetch: myFetch,
  headers: {},
  userAgent: 'billing-service/1.4',
  disableUpdateNotice: true,
})
```

| Option | Default | Notes |
| --- | --- | --- |
| `apiKey` | `NEXTMAIL_API_KEY` | Read from the environment by `init` and `createNextmail`. Must begin `oe_live_` or `oe_test_`. |
| `baseUrl` | `https://api.nextmail.sh` | Or `NEXTMAIL_BASE_URL`. A trailing slash is trimmed, and `init` and `createNextmail` put `https://` in front of a bare host, or `http://` in front of a host on this machine: localhost, a `127.x.x.x` address or `::1`. A credential is never sent over plain `http` to any other host, and `0.0.0.0` or `[::]` throws when the client is built, because those are addresses a server listens on, not ones to send requests to. |
| `timeoutMs` | 30000 | Per attempt, not per call. Covers reading the body, not just the headers. 0 disables it. |
| `maxRetries` | 2 | Extra attempts after the first, on calls that are safe to repeat. Set on the client, not per call. |
| `fetch` | the global | Bound for you. Pass one for a proxy, a Worker binding or a test double. |
| `headers` | `{}` | Sent on every request. |
| `userAgent` | `nextmail-sdk/<version>` | Sent from every runtime except a browser, which does not allow setting it. |
| `disableUpdateNotice` | false | Skips the once-per-process check for a newer version on npm. The check only runs when output goes to a terminal, and `NEXTMAIL_DISABLE_UPDATE_NOTICE` turns it off too. |
| `dangerouslyAllowBrowser` | false | Lets the client start where `window` and `document` exist. Meant for a test harness that defines them, not for a page. |

## What it refuses before sending

These throw a plain `Error` from the line that had the wrong value in it, rather than surfacing as a confusing failure on your first send. The message says what was wrong and what to pass instead.

| Refused | Why |
| --- | --- |
| No key at all | Neither `apiKey` nor `NEXTMAIL_API_KEY` was set, so there is nothing to authenticate with. |
| A session cookie or session token | Only `oe_live_` and `oe_test_` authenticate here, and the API says so too. The check is a prefix and nothing more, so a revoked key still fails on the wire. |
| A `baseUrl` that is not an http or https URL | Nothing else can be fetched, and an unvalidated one would fail later as a raw `TypeError` from somewhere else entirely. |
| A browser | The key would be readable by anyone who opens devtools. See the section below. |
| No `fetch` anywhere | Pass one as `fetch`, or run on Node 20+. |
| An empty or all-dots id on any method | Thrown when the method is called. A path segment of dots is removed by every URL parser, so the request would reach a different endpoint. |

> There is no `testMode` option and there will not be one. The key scheme is part of the credential rather than a hint, so mode is a property of the key. `nextmail.mode` reads the prefix and decides nothing.

## One client, several keys

Build the client once and share it. A fresh instance per request throws away the fetch binding and the configuration for nothing, and none of the state on it is per-caller.

For the case that would otherwise force one instance per key, such as a job sending on behalf of several workspaces, pass `apiKey` on the call. It replaces the Authorization header for that request and leaves nothing behind on the client.

**per-call-key.ts**

```
await nextmail.emails.send(message)

await nextmail.emails.send(message, { apiKey: workspace.apiKey })

await nextmail.threads.list({ folder: 'inbox', apiKey: workspace.apiKey })
await nextmail.webhooks.list({ apiKey: workspace.apiKey })
```

Every method outside `tempMail` takes it in its last argument, beside `signal`, and on a list that is the same object as the filters. It is checked before the request is sent, by the same rule the constructor uses, so a typo throws an `Error` naming `{ apiKey } on this call` rather than a 401 about a credential you then have to go and find. A retried call keeps the key it was given.

`signal` is an `AbortSignal`. Aborting it stops the request, and any retry waiting behind it.

> `nextmail.mode` describes the key the client was CONSTRUCTED with and does not follow an override. Once one client serves several keys there is no single mode to report, so read it off the key you passed.

## From a browser

The client refuses to start in a browser and throws before any request goes out. A key in a page is a key you have published: it can send mail and read the mailbox for anyone who opens devtools. Call it from a server, a serverless function or a script instead.

Disposable inboxes are the exception. `createTempMail()` builds a client that carries no API key, so it is safe in a page. It creates inboxes anonymously, and each read sends the inbox token that `create` returned, or the newer one `extend` returned, either per call as `inboxToken` or once as `createTempMail({ inboxToken })`.

**temp-mail.ts**

```
import { createTempMail } from '@nextmail/sdk'

const tempMail = createTempMail()

const inbox = await tempMail.create()
const { items, expiresAt } = await tempMail.listMessages(inbox.id, { inboxToken: inbox.token })
```

> Where you pass `dangerouslyAllowBrowser: true` anyway, the API allows exactly `Content-Type`, `Authorization` and `Idempotency-Key` through its CORS preflight, so an extra header in `headers` fails the preflight rather than the request, and what a browser reports for that says nothing useful.

## OAuth access tokens (not built yet)

An app a person connected over OAuth, such as a command line tool or an agent, holds an access token rather than an API key. Pass it as `accessToken`, either the token itself, or a function that returns it or a promise of it. The function is called once for every call, and that call’s retries reuse what it returned, so renew the token inside it when it is close to expiring and the client never has to be rebuilt.

**access-token.ts**

```
import { Nextmail } from '@nextmail/sdk'

export const nextmail = new Nextmail({
  accessToken: async () => session.freshAccessToken(),
})

const me = await nextmail.me.get()

if (me.object === 'oauth_token') console.log(me.clientId, me.expiresAt)
```

| Case | What happens |
| --- | --- |
| `apiKey` and `accessToken` together, or neither | The constructor throws. With neither, the message names `NEXTMAIL_API_KEY` and `NEXTMAIL_ACCESS_TOKEN`. |
| A value that is not a token | A token is 1 to 512 characters and does not begin `oe_`, the check `isAccessToken` makes. A string that fails it throws from the constructor, and a function that resolves to one rejects the call before anything is sent. |
| `NEXTMAIL_ACCESS_TOKEN` | Read by `init`, `createNextmail` and the shared `nextmail` when you pass neither credential and `NEXTMAIL_API_KEY` is not set, so a key in the environment wins. |
| A function that throws | The call rejects with that error, unchanged, and nothing is sent. |
| A per-call `{ apiKey }` | Replaces the token for that one request, and the function is not called. |
| `mode` | Always `live` under a token. |
| `createTempMail()` | Sends no credential, whatever the environment holds. |
| `me.get()` and `me.ping()` | For a token, `get` answers with `object` set to `oauth_token`, `id` and `roleId` null, the connected app’s `clientId`, and `expiresAt`, when the person’s approval of the app runs out. `ping` answers with `kind` set to `oauth`, `keyId` null and the `clientId`. `KeyResource` and `PingResource` become unions, so narrow on `object` or `kind` before reading `id` or `keyId`. |

> A token acts for a person and reads their mail as they can, so keep it on a server like a key. The client refuses to start in a browser with one too.

## Verification codes (not built yet)

Before a sensitive change, such as deleting a domain or changing a webhook, the API asks an access token for the verification code the web app would ask the person for. The call throws `NextmailApiError` whose `isStepUpRequired` is true, and nothing was changed. Ask for a code, verify the one the person gives you, then make the call again. An API key is never asked.

**step-up.ts**

```
import { NextmailApiError, nextmail } from '@nextmail/sdk'

try {
  await nextmail.domains.delete(domain.id)
} catch (error) {
  if (!(error instanceof NextmailApiError) || !error.isStepUpRequired) throw error

  const challenge = await nextmail.security.beginStepUp()

  const code = await ask(
    challenge.method === 'email'
      ? `Enter the code we emailed to ${challenge.sentTo}`
      : 'Enter the code from your authenticator app, or a backup code',
  )

  await nextmail.security.verifyStepUp({ code })
  await nextmail.domains.delete(domain.id)
}
```

| Method | What it does |
| --- | --- |
| `security.stepUpStatus()` | Whether the app is verified right now (`elevated`, `elevatedUntil`), how the next code is checked (`method`, `email` or `totp`), and `minutes`, the length of the window. It sends nothing, and it does not report a pause. |
| `security.beginStepUp(body?)` | Opens a challenge. With `email` a six-digit code goes to the address the person signs in with, and `sentTo` shows it masked. With `totp` they read one from their authenticator app or use a backup code. A challenge that is still open and has tries left is reused unless you pass `{ resend: true }`, and a locked or expired one is replaced by a plain call. Each app may open 5 an hour and 20 in 24 hours for each person, and the next throws 429 `step_up_throttled`. |
| `security.verifyStepUp({ code })` | Checks the code and unlocks sensitive changes for this app for 60 minutes, until `elevatedUntil`, over REST and through the MCP tools that make the same changes. After 10 wrong codes in 24 hours from this app, or 20 from all of the person’s apps together, this call and `beginStepUp` throw 429 `step_up_locked` with a message that says when verification resumes. |

> The client never asks for a code or repeats the call by itself, and none of the three methods is retried automatically, because a retry after a lost answer could send a second email or spend a second try. They need no scope, and an API key calling one gets 400 `step_up_not_applicable`. `STEP_UP_ERROR_CODES` names every way a verification can fail, and the API errors page says what to do about each.

- [Verification codes](https://nextmail.sh/docs/api/authentication.md): Which operations ask for a code, and the limits on asking.
