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

# Configuration

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

## Options

**clients.php**

```
use OpenEmail\OpenEmail;

$client = new OpenEmail();

OpenEmail::init(timeout: 10);
OpenEmail::getClient()->me->ping();

$billing = new OpenEmail(apiKey: (string) getenv('OPENEMAIL_BILLING_API_KEY'));

echo $client->mode, ' ', $billing->mode, PHP_EOL;
```

| Entry point | What it gives you |
| --- | --- |
| `new OpenEmail(...)` | A client built from the named arguments you pass. Anything you leave out is read from the environment: the key from `OPENEMAIL_API_KEY` or a token from `OPENEMAIL_ACCESS_TOKEN` when you pass no credential, and the base URL from `OPENEMAIL_BASE_URL` when you pass none. |
| `OpenEmail::createClient(...)` | The same client as `new OpenEmail(...)`, for code that would rather call a factory. |
| `OpenEmail::init(...)` | Builds a client, keeps it as the shared client and returns it. It takes the same named arguments. |
| `OpenEmail::getClient()` | The shared client, from anywhere in the process. Called before `init`, it builds one from the environment on the first call. |
| `OpenEmail::resetClient()` | Drops the shared client, so the next `getClient()` builds a fresh one, which is what a test wants between cases. |

Casting `getenv()` to a string is deliberate. A variable that is not set becomes an empty key, which the client refuses with a message naming the variable it needs, where `null` would quietly fall back to `OPENEMAIL_API_KEY`.

**options.php**

```
use OpenEmail\Http\CurlHttpClient;
use OpenEmail\OpenEmail;

$client = new OpenEmail(
    apiKey: (string) getenv('OPENEMAIL_API_KEY'),
    baseUrl: 'https://api.openemail.uk',
    httpClient: new CurlHttpClient(),
    maxRetries: 2,
    timeout: 30,
    userAgent: 'billing-service/1.4',
    headers: ['X-Team' => 'billing'],
    disableUpdateNotice: true,
);
```

| Option | Default | Notes |
| --- | --- | --- |
| `apiKey:` | `OPENEMAIL_API_KEY` | Must begin `oe_live_` or `oe_test_`. Read from the environment only when you pass neither `apiKey:` nor `accessToken:`. |
| `accessToken:` | `OPENEMAIL_ACCESS_TOKEN` | An OAuth access token, or a callable that returns one. See OAuth access tokens below. Pass a key or a token, never both. |
| `baseUrl:` | `https://api.openemail.uk` | Or `OPENEMAIL_BASE_URL`. Trailing slashes are trimmed, and `https://` goes 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 `[::]` is refused when the client is built, because those are addresses a server listens on, not ones to send requests to. |
| `timeout:` | 30 | Seconds per attempt, not per call, covering connecting and reading the whole response. 0 turns it off. `files->upload` waits at least 600 seconds unless you pass `timeout:` on that call. |
| `maxRetries:` | 2 | Extra attempts after the first, on calls that are safe to repeat. Set on the client, not per call. 0 turns retries off. |
| `httpClient:` | `CurlHttpClient` | The HTTP layer: anything that implements `OpenEmail\Http\HttpClient`, such as `Psr18HttpClient` around Guzzle or Symfony HttpClient, or a fake in a test. The HTTP clients page covers each. |
| `headers:` | `[]` | Sent on every request. |
| `userAgent:` | `openemail-php/<version>` | Sent on every request. |
| `disableUpdateNotice:` | false | Skips the once-per-process check for a newer version on Packagist. The check only runs on the command line when standard output is a terminal, and `OPENEMAIL_DISABLE_UPDATE_NOTICE` turns it off too. |

## Environment variables

| Variable | What it does |
| --- | --- |
| `OPENEMAIL_API_KEY` | The key a client uses when you pass neither `apiKey:` nor `accessToken:`. |
| `OPENEMAIL_ACCESS_TOKEN` | An OAuth access token, read only when you pass neither credential and `OPENEMAIL_API_KEY` is not set, so a key in the environment wins. |
| `OPENEMAIL_BASE_URL` | The base URL when you pass none. A bare host such as `localhost:2222` gets its scheme added. |
| `OPENEMAIL_DISABLE_UPDATE_NOTICE` | Any value that is not empty turns the update notice off, for every client in the process. |
| `HTTPS_PROXY` and `NO_PROXY`, or `https_proxy` and `no_proxy` | The proxy cURL connects through, and the hosts that go direct. See Proxies and TLS below. |

> Each variable is read with `getenv()` first, then from `$_SERVER` and `$_ENV`, so a value your framework loaded from a `.env` file counts too. A variable that is set but empty counts as not set.

## What it refuses before sending

These throw `OpenEmail\Exception\InvalidArgumentException` 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, and it never repeats a credential.

| Refused | Why |
| --- | --- |
| No credential at all | Neither `apiKey:` nor `accessToken:` was passed and neither variable was set, so there is nothing to authenticate with. Thrown when the client is built. |
| A key and a token together | Every request carries one credential, so the client cannot tell which you meant. |
| A session cookie, a session token or a key for another service | 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, as an `AuthenticationException`. |
| A `baseUrl:` that is not an http or https URL, or one with a user name or password in it | Nothing else can be reached, and a credential belongs in `apiKey:` or `accessToken:`, not in the URL. Thrown when the client is built. |
| A credential over plain `http` to a host that is not on this machine | Thrown by the call, before anything is sent. Use an `https` base URL. |
| A negative `timeout:` | Pass seconds, or 0 for no timeout. Thrown when the client is built, or by the call for a timeout passed to one call. |
| A header name that is not a token, or a line break or other control character in a header value | Checked in `headers:`, `userAgent:` and `idempotencyKey:`, because a line break would start a second header. Spaces, tabs and line breaks around a value are trimmed first, as `fetch` trims them, so a key read from a file that ends in a newline still works. |
| 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. An id that is not valid UTF-8 is refused too. |
| Attachment content that is not base64 | A string is always read as base64, so raw bytes in one would be sent as garbage. Encode them with `OpenEmail::toBase64()`, or pass an `SplFileInfo`, a stream or a PSR-7 stream and the client encodes it. |

The class extends PHP’s own `InvalidArgumentException`, so code that already catches that keeps working, and it implements `OpenEmail\Exception\OpenEmailException` like every other exception the package throws. A value of the wrong type, such as a number where a string id goes, is a `TypeError` from PHP itself, because every method declares its types.

> 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. `$client->mode` reads the prefix, `live` or `test`, and decides nothing.

## One client, several keys

Build the client once and share it. A fresh client per request discards its open connection for nothing, and none of the state on it is per caller.

For the case that would otherwise force one client 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.php**

```
$message = [
    'from' => 'billing@acme.com',
    'to' => 'ada@example.com',
    'subject' => 'Your invoice',
    'text' => 'Attached.',
];
$workspaceKey = (string) getenv('OPENEMAIL_API_KEY');

$client->emails->send($message);

$client->emails->send($message, apiKey: $workspaceKey);

$client->threads->list(folder: 'inbox', apiKey: $workspaceKey);
$client->webhooks->list(apiKey: $workspaceKey);
```

Every method outside `tempMail` takes it as its last named argument, after the filters on a list, and the `tempMail` methods take `inboxToken:` instead. It is checked before the request is sent, by the same rule the client uses, so a typo throws an `InvalidArgumentException` about the apiKey passed to 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.

> `$client->mode` describes the key the client was BUILT 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. `var_dump($client)` shows the mode and the base URL, never the key, and every parameter that takes a credential is marked `#[\SensitiveParameter]`, so a stack trace prints a placeholder in its place.

## Endpoints no method wraps

`$client->raw` is the transport every method goes through. `$client->raw->request()` calls a path no method wraps yet, with the client’s credential, base URL, timeout and retry policy applied, and returns the decoded body the way a method does.

**raw_request.php**

```
$ping = $client->raw->request('/ping');

$label = $client->raw->request('/labels', method: 'POST', body: ['name' => 'Invoices']);

var_dump($ping, $label);
```

| Named argument | What it does |
| --- | --- |
| `method:` | `GET` unless you say otherwise: `POST`, `PUT`, `PATCH` or `DELETE`. |
| `query:` | An array of query parameters. null and empty values are left out, a list is joined with commas, and a `DateTimeInterface` is sent as an ISO 8601 instant in UTC. |
| `body:` | An array, sent as JSON. |
| `raw:` and `contentType:` | Bytes to send as they are, as a string, a stream resource, an `SplFileInfo` or a PSR-7 stream, with `application/octet-stream` unless you name a type. |
| `accept:` and `binary:` | An `accept:` other than JSON returns the body as text, and `binary: true` returns it as a string of bytes. |
| `idempotent:` and `idempotencyKey:` | `idempotent: true` attaches an `Idempotency-Key`, generated unless you pass your own. |
| `repeatable:` | Whether a failure is retried. Only a GET is, unless you pass `repeatable: true`. |
| `anonymous:` | `true` sends no credential at all. |
| `apiKey:`, `inboxToken:` and `timeout:` | The same per-call credentials, and a timeout in seconds for this call alone. |

> The path must begin with a single `/`, and a path whose finished URL would leave the base URL’s origin throws `InvalidArgumentException` before anything is sent, so the credential never reaches another host.

## Disposable inboxes

`OpenEmail::createTempMail()` builds a client for disposable inboxes that carries no API key and reads none from the environment. 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 `OpenEmail::createTempMail(inboxToken: ...)`.

**temp_mail.php**

```
use OpenEmail\OpenEmail;

$tempMail = OpenEmail::createTempMail();

$inbox = $tempMail->create();
$page = $tempMail->listMessages($inbox['id'], inboxToken: $inbox['token']);

echo count($page->items), ' ', $page->expiresAt, PHP_EOL;
```

> `OpenEmail::createTempMail()` takes `baseUrl:`, `httpClient:`, `maxRetries:`, `timeout:`, `userAgent:`, `headers:` and `disableUpdateNotice:` like any client, and reads `OPENEMAIL_BASE_URL` when you pass no base URL.

## OAuth access tokens

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 callable that returns it, such as a closure or a first-class callable. The callable runs 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.php**

```
use OpenEmail\OpenEmail;

$tokens = ['current' => 'token-from-your-oauth-flow'];

$oauthClient = new OpenEmail(accessToken: fn(): string => $tokens['current']);

$me = $oauthClient->me->get();

if ($me['object'] === 'oauth_token') {
    echo $me['clientId'], ' ', $me['expiresAt'], PHP_EOL;
}
```

| Case | What happens |
| --- | --- |
| `apiKey:` and `accessToken:` together, or neither | The client throws `InvalidArgumentException` when it is built. 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 `OpenEmail::isAccessToken()` makes. A string that fails it is refused when the client is built, and a callable that returns one makes the call throw `InvalidArgumentException` before anything is sent. |
| `OPENEMAIL_ACCESS_TOKEN` | Read when you pass neither credential and `OPENEMAIL_API_KEY` is not set, so a key in the environment wins. |
| A callable that throws | The call throws that exception, unchanged, and nothing is sent. |
| A per-call `apiKey:` | Replaces the token for that one request, and the callable is not called. |
| `$client->mode` | Always `live` under a token. |
| `OpenEmail::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`. Check `object` or `kind` before you read `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.

## Verification codes

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 a `PermissionException`, a 403 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.php**

```
use OpenEmail\Exception\ApiException;

$domainId = 'b3e1f0a4-6c2d-4e8a-9f17-2d5c8a0b4e6f';

try {
    $client->domains->delete($domainId);
} catch (ApiException $error) {
    if (!$error->isStepUpRequired()) {
        throw $error;
    }

    $challenge = $client->security->beginStepUp();

    if ($challenge['method'] === 'email') {
        echo 'Enter the code we emailed to ', $challenge['sentTo'], PHP_EOL;
    } else {
        echo 'Enter the code from your authenticator app, or a backup code', PHP_EOL;
    }

    $client->security->verifyStepUp(['code' => trim((string) fgets(STDIN))]);
    $client->domains->delete($domainId);
}
```

| 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()` | 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 a 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 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 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 a 400 `step_up_not_applicable`. `OpenEmail\Constants\StepUpErrorCodes` 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.

## The update notice

When a newer version of the package is on Packagist, the client says so once per process, on standard error, as a line such as `ℹ openemail/sdk 0.0.2 is available, you are on 0.0.1.` followed by the package’s page. The check runs only on the command line, when standard output is a terminal, and never under a web server. It starts when the first client is built and runs beside your requests, and at the end of the script it waits for whatever is left of a two second budget. A failure to reach Packagist is ignored.

> The check makes its own request with cURL, outside the client’s `httpClient:`, so a fake HTTP client in a test never sees it. Pass `disableUpdateNotice: true` or set `OPENEMAIL_DISABLE_UPDATE_NOTICE` to turn it off.

## Proxies and TLS

The default `CurlHttpClient` leaves proxies to cURL, which reads `https_proxy` or `HTTPS_PROXY` for the proxy and `no_proxy` or `NO_PROXY` for the hosts that go direct. Pass `proxy:` to name one in code instead. A user name and password in the proxy URL are sent to the proxy.

**curl_options.php**

```
use OpenEmail\Http\CurlHttpClient;
use OpenEmail\OpenEmail;

$client = new OpenEmail(httpClient: new CurlHttpClient(
    caBundle: '/etc/ssl/certs/corporate-ca.pem',
    proxy: 'http://proxy.internal:3128',
    curlOptions: [CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4],
));
```

> Connections use TLS 1.2 or later and check the server’s certificate and host name, and redirects are never followed. `caBundle:` names the certificate authorities to trust, for a proxy that inspects TLS. `curlOptions:` sets any other cURL option, but the settings a request needs always win: the URL and its port, the method, the headers, the body and redirects staying off. `CURLOPT_REQUEST_TARGET` is refused, and an option cURL does not accept throws an `InvalidArgumentException` that names it.

- [HTTP clients](https://openemail.uk/docs/php/http-clients.md): Use Guzzle, Symfony HttpClient or a fake in tests.
