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

# Configuration

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

## Options

**clients.py**

```
import os

from openemail import AsyncOpenEmail, OpenEmail, init, openemail

init(os.environ['OPENEMAIL_API_KEY'])
openemail.me.ping()

billing = OpenEmail(os.environ['BILLING_API_KEY'])

pinned = OpenEmail('oe_live_...', base_url='https://api.openemail.uk')

background = AsyncOpenEmail()
```

| Entry point | What it gives you |
| --- | --- |
| `init(...)` | Configures the shared client and returns it. `openemail` is that client from then on, in every module, and anything you leave out is read from the environment. |
| `openemail` | The shared client. Used before `init`, it builds itself from `OPENEMAIL_API_KEY` and `OPENEMAIL_BASE_URL` on the first call. |
| `OpenEmail(...)` | A separate client, for a second key beside the shared one, or to build the instance your own module exports. Anything you leave out is read from the environment, as `init` does, and `create_client` is the same class under another name. |
| `AsyncOpenEmail(...)` | A separate asynchronous client with the same options and every method awaited. The shared `openemail` is synchronous, so this one you build yourself. |
| `get_client()` and `reset_client()` | `get_client` returns the shared client itself, building it from the environment when `init` has not run. `reset_client` forgets it, so the next use builds a new one. |

**options.py**

```
import httpx
from openemail import init

init(
    'oe_live_...',
    base_url='https://api.openemail.uk',
    timeout=30,
    max_retries=2,
    http_client=httpx.Client(proxy='http://proxy.internal:3128'),
    headers={'X-Team': 'billing'},
    user_agent='billing-service/1.4',
    disable_update_notice=True,
)
```

| Option | Default | Notes |
| --- | --- | --- |
| `api_key` | `OPENEMAIL_API_KEY` | Read from the environment by `init` and `OpenEmail`. Must begin `oe_live_` or `oe_test_`. |
| `access_token` | `OPENEMAIL_ACCESS_TOKEN` | An OAuth access token, or a function that returns one, in place of `api_key`. See OAuth access tokens below. |
| `base_url` | `https://api.openemail.uk` | Or `OPENEMAIL_BASE_URL`. A trailing slash is trimmed, and `init` and `OpenEmail` 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 `[::]` raises when the client is built, because those are addresses a server listens on, not ones to send requests to. |
| `timeout` | 30 | In seconds, per attempt, not per call. Covers reading the body, not just the headers. 0 disables it. `files.upload` allows at least 600 seconds unless the call passes its own `timeout`. |
| `max_retries` | 2 | Extra attempts after the first, on calls that are safe to repeat. Set on the client, not per call. |
| `http_client` | a new `httpx.Client` | Pass your own for a proxy, your own TLS settings, a mounted transport or a test double: an `httpx.Client` to `OpenEmail`, an `httpx.AsyncClient` to `AsyncOpenEmail`. Closing the client leaves one you passed open. |
| `headers` | `{}` | Sent on every request. |
| `user_agent` | `openemail-python/<version>` | Sent on every request. |
| `disable_update_notice` | `False` | Skips the once-per-process check for a newer version on PyPI. The check only runs when output goes to a terminal, and `OPENEMAIL_DISABLE_UPDATE_NOTICE` turns it off too. |

## What it refuses before sending

These raise `ValueError`, or `TypeError` where the table says so, before any request is sent, 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 `api_key` nor `OPENEMAIL_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 `base_url` that is not an http or https URL | Nothing else can be fetched, so the client refuses it when it is built rather than failing on the first request. |
| A `base_url` on `0.0.0.0` or `[::]` | An address a server listens on, not one to send requests to. The message names `127.0.0.1` or `[::1]` with the same port instead. |
| A credential over plain `http` | Refused on the call, before the request leaves, unless the server is on this machine. Anyone on the network could read it. |
| An empty or all-dots id on any method | Raised when the method is called. A path segment of dots is removed by every URL parser, so the request would reach a different endpoint. |
| An `http_client` of the wrong kind | A `TypeError` when the client is built. `OpenEmail` takes an `httpx.Client`, and `AsyncOpenEmail` an `httpx.AsyncClient`. |
| A body value JSON cannot carry | A `TypeError` naming its type. JSON types pass through, and a `datetime`, a `date` or a `set` is converted for you. |

> There is no `test_mode` 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. `client.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 connection pool and the configuration for nothing, and none of the state on it is per-caller.

One client is safe to share between threads. `close()` or the end of a `with` block closes the connection pool it opened, and an `http_client` you passed stays open for you to close.

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

**per_call_key.py**

```
from openemail.types import EmailSend

workspace_key = 'oe_live_...'

message: EmailSend = {'from': sender, 'to': recipient, 'subject': subject, 'text': text}

client.emails.send(message)

client.emails.send(message, api_key=workspace_key)

client.threads.list(folder='inbox', api_key=workspace_key)
client.webhooks.list(api_key=workspace_key)
```

Every method outside `temp_mail` takes it as a keyword argument, beside `timeout`. It is checked before the request is sent, by the same rule the constructor uses, so a typo raises a `ValueError` naming `api_key= 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.

`timeout` is in seconds and replaces the client’s timeout for that one call, on each of its attempts.

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

## An endpoint no method wraps

`client.raw.request` sends a request with the client’s credential, base URL, timeout and retry policy applied, and returns the parsed JSON. It takes `method`, `query`, `body`, `api_key` and `timeout`. It retries a `GET` and sends anything else once, unless you pass `repeatable=True`. `idempotent=True` adds an `Idempotency-Key`, the one you pass as `idempotency_key` or a fresh one.

**raw_request.py**

```
result = client.raw.request('/something-new', method='POST', body={'name': 'Invoices'})
```

> The path must begin with a single `/`. Anything else, such as `//host/x`, raises before a request is sent, and so does a path whose finished URL leaves the base URL’s origin, so the credential it carries never reaches another host.

## Disposable inboxes

`create_temp_mail()` builds a client that carries no API key, and `create_async_temp_mail()` is its asynchronous twin. It creates inboxes anonymously, and each read sends the inbox token that `create` returned, or the newer one `extend` returned, either per call as `inbox_token` or once as `create_temp_mail(inbox_token=...)`.

**temp_mail.py**

```
from openemail import create_temp_mail

temp_mail = create_temp_mail()

inbox = temp_mail.create()
messages = temp_mail.list_messages(inbox['id'], inbox_token=inbox['token'])

print(messages['items'], messages['expiresAt'])
```

## 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 `access_token`, either the token itself, or a function that returns it, which on `AsyncOpenEmail` may be `async`. 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.py**

```
from openemail import OpenEmail

client = OpenEmail(access_token=session.fresh_access_token)

me = client.me.get()

if me['object'] == 'oauth_token':
    print(me['clientId'], me['expiresAt'])
```

| Case | What happens |
| --- | --- |
| `api_key` and `access_token` together, or neither | The constructor raises `ValueError`. With neither, the message names `OPENEMAIL_API_KEY` and `OPENEMAIL_ACCESS_TOKEN`. |
| A value that is not a token | A token is 1 to 512 characters and does not begin `oe_`, the check `is_access_token` makes. A string that fails it raises from the constructor, and a function that returns one fails the call before anything is sent. |
| `OPENEMAIL_ACCESS_TOKEN` | Read by `init`, `OpenEmail` and the shared `openemail` when you pass neither credential and `OPENEMAIL_API_KEY` is not set, so a key in the environment wins. |
| A function that raises | The call raises that error, unchanged, and nothing is sent. |
| A function on `OpenEmail` that returns an awaitable | A `ValueError`, since the synchronous client cannot wait for it. On `AsyncOpenEmail` the function may be `async`. |
| A per-call `api_key` | Replaces the token for that one request, and the function is not called. |
| `mode` | Always `live` under a token. |
| `create_temp_mail()` | 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` set to `None`, 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` set to `None` and the `clientId`. `KeyResource` and `PingResource` are unions, so narrow on `object` or `kind` before reading `clientId` or `expiresAt`. |

> A token acts for a person and reads their mail as they can, so keep it on a server like a key.

## 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 raises an `OpenEmailApiError` whose `is_step_up_required` 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.py**

```
from openemail import OpenEmailApiError

try:
    client.domains.delete(domain_id)
except OpenEmailApiError as error:
    if not error.is_step_up_required:
        raise

    challenge = client.security.begin_step_up()

    if challenge['method'] == 'email':
        prompt = f'Enter the code we emailed to {challenge.get("sentTo")}: '
    else:
        prompt = 'Enter the code from your authenticator app, or a backup code: '

    client.security.verify_step_up({'code': input(prompt)})
    client.domains.delete(domain_id)
```

| Method | What it does |
| --- | --- |
| `security.step_up_status()` | 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.begin_step_up(body=None)` | 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 raises a 429 `step_up_throttled`. |
| `security.verify_step_up({'code': 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 `begin_step_up` raise a 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 neither `begin_step_up` nor `verify_step_up` 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://openemail.uk/docs/api/authentication.md): Which operations ask for a code, and the limits on asking.
