# Test servers

Send from staging and CI without delivering anything, and act out bounces and complaints.

A test server takes mail exactly as a live server does, and delivers none of it. Use one for your staging environment and your CI, and keep a live server for mail that must arrive.

A test server works from the moment your account exists, before Sendora approves it and before payment. A send to it is checked as a live send is: the key, the From domain and the suppression list. Its limits are the test servers' own, and a paused or closed account's test servers take nothing. The message is stored in the log, and your webhooks fire. It never reaches a mail server, so nothing reaches a recipient.

## Create a test server

In the dashboard, tick **Testserver** when you create the server under [Servers › New server](https://app.sendora.se/servers/new). With the account key, set `mode: "test"`:

cURL:

```bash
curl -X POST https://api.sendora.se/v1/servers \
  -H "Authorization: Bearer $SENDORA_ACCOUNT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "CI",
  "mode": "test"
}'
```

TypeScript:

```ts
import { SendoraAccount } from '@sendora/sdk';

const account = new SendoraAccount({
  token: process.env.SENDORA_ACCOUNT_TOKEN,
});

const server = await account.servers.create({
  name: 'CI',
  mode: 'test',
});

console.log(server.token.token);
```

Python:

```python
import os

from sendora import SendoraAccount

account = SendoraAccount(
    os.environ["SENDORA_ACCOUNT_TOKEN"]
)

server = account.servers.create(name="CI", mode="test")

print(server.token.token)
```

Response:

```json
{
  "serverId": "e4a7c2b9-3d1f-4b6e-9a8c-2f5d7b1e0c63",
  "name": "CI",
  "mode": "test",
  "createdAt": "2026-09-23T09:00:00.000Z",
  "token": {
    "tokenId": "c8e1f3a5-7b9d-4c2e-8f60-1a3b5c7d9e2f",
    "name": "default",
    "prefix": "sk_test_a1b2c3d4e5",
    "createdAt": "2026-09-23T09:00:00.000Z",
    "revokedAt": null,
    "token": "sk_test_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v"
  }
}
```

A test server's keys start with `sk_test_`, so a key shows its kind wherever it is written down. The server has a **Test** label in the dashboard.

A server's mode is fixed when you create it. An update that sets `mode` fails with `400 mode_immutable`.

## Send with it

Send as you would on a live server, with the test server's key. The answer, the message in the log and every webhook have `test: true`:

cURL:

```bash
curl -X POST https://api.sendora.se/v1/email \
  -H "Authorization: Bearer $SENDORA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "from": {
    "email": "no-reply@example.se",
    "name": "Example AB"
  },
  "to": [
    "hardbounce+signup-test@simulator.sendora.se"
  ],
  "subject": "Confirm your address",
  "text": "Confirm at https://example.se/confirm/abc123."
}'
```

TypeScript:

```ts
import { Sendora } from '@sendora/sdk';

const sendora = new Sendora({
  token: process.env.SENDORA_API_TOKEN,
});

const { messageId, test } = await sendora.email.send({
  from: {
    email: 'no-reply@example.se',
    name: 'Example AB',
  },
  to: ['hardbounce+signup-test@simulator.sendora.se'],
  subject: 'Confirm your address',
  text: 'Confirm at https://example.se/confirm/abc123.',
});

console.log(messageId, test);
```

Python:

```python
import os

from sendora import Sendora

sendora = Sendora(os.environ["SENDORA_API_TOKEN"])

accepted = sendora.email.send(
    from_={
        "email": "no-reply@example.se",
        "name": "Example AB",
    },
    to=["hardbounce+signup-test@simulator.sendora.se"],
    subject="Confirm your address",
    text="Confirm at https://example.se/confirm/abc123.",
)

print(accepted.message_id, accepted.test)
```

Response:

```json
{
  "messageId": "0b6f2d8e-4a1c-4e7b-9d3f-5c8a2e6b1f40",
  "status": "accepted",
  "submittedAt": "2026-09-23T09:01:00.000Z",
  "test": true
}
```

[SMTP submission](https://sendora.se/docs/smtp) works too, with the test server's key as both user name and password. The SMTP reply to the message says that it is a test.

## The simulator's addresses

A test server reports every recipient as delivered, without sending anything, except at `simulator.sendora.se`. There, the part before the `@` decides what happens:

| Address                           | What happens                                                                                                          |
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `hardbounce@simulator.sendora.se` | A hard bounce: the `bounced` webhook with `hard: true`, and the address is suppressed on that [stream](https://sendora.se/docs/streams) |
| `softbounce@simulator.sendora.se` | A soft bounce, a full mailbox: the `bounced` webhook with `hard: false`, and no suppression                           |
| `deferred@simulator.sendora.se`   | The `deferred` webhook, then delivered a minute later                                                                 |
| `complaint@simulator.sendora.se`  | Delivered, then a spam complaint: the `spam_complaint` webhook, and the address is suppressed                         |
| any other address                 | Delivered                                                                                                             |

Case does not matter. A `+label` after the name is kept, so `hardbounce+signup-test@simulator.sendora.se` bounces like `hardbounce@`. A suppression covers the whole address, label included, as on a live server. Give each test run its own label, so that a bounce in one run never blocks the next.

A live server refuses these addresses before anything is stored. Sent with a live server's key, the same request fails with `test_address_on_live_server`:

cURL:

```bash
curl -X POST https://api.sendora.se/v1/email \
  -H "Authorization: Bearer $SENDORA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "from": {
    "email": "no-reply@example.se",
    "name": "Example AB"
  },
  "to": [
    "hardbounce+signup-test@simulator.sendora.se"
  ],
  "subject": "Confirm your address",
  "text": "Confirm at https://example.se/confirm/abc123."
}'
```

TypeScript:

```ts
import { Sendora, SendoraError } from '@sendora/sdk';

const sendora = new Sendora({
  token: process.env.SENDORA_API_TOKEN,
});

try {
  await sendora.email.send({
    from: {
      email: 'no-reply@example.se',
      name: 'Example AB',
    },
    to: ['hardbounce+signup-test@simulator.sendora.se'],
    subject: 'Confirm your address',
    text: 'Confirm at https://example.se/confirm/abc123.',
  });
} catch (error) {
  if (
    error instanceof SendoraError &&
    error.code === 'test_address_on_live_server'
  ) {
    console.log(error.addresses);
  } else {
    throw error;
  }
}
```

Python:

```python
import os

from sendora import Sendora, SendoraError

sendora = Sendora(os.environ["SENDORA_API_TOKEN"])

try:
    sendora.email.send(
        from_={
            "email": "no-reply@example.se",
            "name": "Example AB",
        },
        to=[
            "hardbounce+signup-test@simulator.sendora.se"
        ],
        subject="Confirm your address",
        text=(
            "Confirm at "
            "https://example.se/confirm/abc123."
        ),
    )
except SendoraError as error:
    if error.code == "test_address_on_live_server":
        print(error.addresses)
    else:
        raise
```

Response:

```json
{
  "error": "test_address_on_live_server",
  "message": "Addresses at simulator.sendora.se act out an outcome on a test server and are never sent to from a live one. Send them from a test server.",
  "addresses": [
    "hardbounce+signup-test@simulator.sendora.se"
  ]
}
```

## Limits and cost

Test mail is free, and it never counts toward your plan or the account's monthly cap. All of the account's test servers share limits of their own, on [Limits](https://sendora.se/docs/limits#test-servers). A refusal under them has the scope `test`. Write to support if your CI needs more.

A test server counts toward the number of servers the account may have. A test server has no inbound stream, so receive mail on a live server. Creating one fails with `422 stream_kind_not_allowed`.

A test server keeps messages for less time than a live server, as [Limits](https://sendora.se/docs/limits#retention) gives. The content of attachments is deleted as soon as the simulator has the message.

## Going live

A test server never becomes live. Create a live server and its key, and move your production configuration to them. Then make your application refuse a test key where mail must arrive:

Over HTTP:

A test key starts with `sk_test_`, and a live server's key with `sk_` alone. Check for `sk_test_` when your application starts.

With the TypeScript SDK:

`isTestKey` tells the two kinds of key apart:

```ts
import { isTestKey, Sendora } from '@sendora/sdk';

const token = process.env.SENDORA_API_TOKEN ?? '';
if (process.env.NODE_ENV === 'production' && isTestKey(token)) {
  throw new Error('SENDORA_API_TOKEN is a test key; nothing would be delivered');
}
const sendora = new Sendora({ token });
```

With the Python SDK:

`is_test_key` tells the two kinds of key apart:

```python
import os

from sendora import Sendora, is_test_key

token = os.environ["SENDORA_API_TOKEN"]
production = os.environ.get("APP_ENV") == "production"
if production and is_test_key(token):
    message = (
        "SENDORA_API_TOKEN is a test key; "
        "nothing would be delivered"
    )
    raise RuntimeError(message)
sendora = Sendora(token)
```

To ask the API instead, read the key's server. The answer gives the server's `mode` and the sending domains a From address may use. It also lists each limit a send meets, with how much this minute and this month have used:

cURL:

```bash
curl https://api.sendora.se/v1/server \
  -H "Authorization: Bearer $SENDORA_API_TOKEN"
```

TypeScript:

```ts
import { Sendora } from '@sendora/sdk';

const sendora = new Sendora({
  token: process.env.SENDORA_API_TOKEN,
});

const { mode, limits } = await sendora.server.get();
if (
  process.env.NODE_ENV === 'production' &&
  mode === 'test'
) {
  throw new Error('This key belongs to a test server');
}

console.log(limits);
```

Python:

```python
import os

from sendora import Sendora

sendora = Sendora(os.environ["SENDORA_API_TOKEN"])

server = sendora.server.get()
if (
    os.environ.get("APP_ENV") == "production"
    and server.mode == "test"
):
    message = "This key belongs to a test server"
    raise RuntimeError(message)

print(server.limits)
```

Response:

```json
{
  "serverId": "e4a7c2b9-3d1f-4b6e-9a8c-2f5d7b1e0c63",
  "name": "CI",
  "mode": "test",
  "createdAt": "2026-09-23T09:00:00.000Z",
  "limits": [
    {
      "scope": "test",
      "period": "minute",
      "limit": 300,
      "used": 1,
      "resetsAt": "2026-09-23T09:02:00.000Z"
    },
    {
      "scope": "test",
      "period": "month",
      "limit": 10000,
      "used": 1,
      "resetsAt": "2026-10-01T00:00:00.000Z"
    }
  ],
  "sendingDomains": [
    {
      "domain": "example.se",
      "failingSince": null
    }
  ]
}
```
