Get started
More ways to use this page
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":
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_, 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_.
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 -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. There, the part before the @ decides what happens:
| Address | What happens |
|---|---|
hardbounce@ | A hard bounce: the bounced webhook with hard: true, and the address is suppressed on that stream |
softbounce@ | A soft bounce, a full mailbox: the bounced webhook with hard: false, and no suppression |
deferred@ | The deferred webhook, then delivered a minute later |
complaint@ | Delivered, then a spam complaint: the spam_ 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@ 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_:
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_.
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_ 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_, and a live server’s key with sk_ alone. Check for sk_ 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:
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
}
]
}