Hoppa till innehållet

Get started

Test servers

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. With the account key, set mode: "test":

POST /v1/servers
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"
}'
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);
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

{
  "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:

POST /v1/email
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."
}'
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);
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

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

SMTP submission 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:

AddressWhat happens
hardbounce@simulator.sendora.seA hard bounce: the bounced webhook with hard: true, and the address is suppressed on that stream
softbounce@simulator.sendora.seA soft bounce, a full mailbox: the bounced webhook with hard: false, and no suppression
deferred@simulator.sendora.seThe deferred webhook, then delivered a minute later
complaint@simulator.sendora.seDelivered, then a spam complaint: the spam_complaint webhook, and the address is suppressed
any other addressDelivered

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:

POST /v1/email
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."
}'
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;
  }
}
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

{
  "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. 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 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:

isTestKey tells the two kinds of key apart:

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 });

is_test_key tells the two kinds of key apart:

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)

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

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:

GET /v1/server
curl https://api.sendora.se/v1/server \
  -H "Authorization: Bearer $SENDORA_API_TOKEN"
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);
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 (28 lines)
{
  "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
    }
  ]
}