# Test mode and sandbox

Two things let you build without touching an inbox: test keys, which never send, and the sandbox domain with its simulator addresses, which work in both modes.

## Test keys

Keys that start with `av_test_` run the whole pipeline: validation, sender rules, suppressions, the event log and webhooks. The only thing that does not happen is the send itself. About two seconds after a test send is accepted, the email is marked `sent` and then `delivered` (or bounced or complained, if you used a simulator recipient), and your webhooks receive the same payloads they would in live mode, with `"mode": "test"`.

**curl**

```bash
curl -X POST https://api.avelto.dev/v1/emails \
  -H "Authorization: Bearer av_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <hello@mail.acme.com>",
    "to": "jane@example.com",
    "subject": "Password reset",
    "text": "Nothing is sent. The events and webhooks still fire."
  }'
```

**Node**

```ts
import { Avelto } from "@avelto/sdk";

const avelto = new Avelto("av_test_...");

const { id } = await avelto.emails.send({
  from: "Acme <hello@mail.acme.com>",
  to: "jane@example.com",
  subject: "Password reset",
  text: "Nothing is sent. The events and webhooks still fire.",
});
```

**Python**

```python
import os, requests

r = requests.post(
    "https://api.avelto.dev/v1/emails",
    headers={"Authorization": f"Bearer {os.environ['AVELTO_API_KEY']}"},
    json={
      "from": "Acme <hello@mail.acme.com>",
      "to": "jane@example.com",
      "subject": "Password reset",
      "text": "Nothing is sent. The events and webhooks still fire."
    },
)
r.raise_for_status()
print(r.json())
```

**Go**

```go
package main

import (
	"bytes"
	"fmt"
	"net/http"
	"os"
)

func main() {
	body := []byte(`{"from":"Acme <hello@mail.acme.com>","to":"jane@example.com","subject":"Password reset","text":"Nothing is sent. The events and webhooks still fire."}`)
	req, _ := http.NewRequest("POST", "https://api.avelto.dev/v1/emails", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+os.Getenv("AVELTO_API_KEY"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	fmt.Println(res.Status)
}
```

**Ruby**

```ruby
require "net/http"
require "json"

uri = URI("https://api.avelto.dev/v1/emails")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{ENV["AVELTO_API_KEY"]}"
req["Content-Type"] = "application/json"
req.body = JSON.generate({
  from: "Acme <hello@mail.acme.com>",
  to: "jane@example.com",
  subject: "Password reset",
  text: "Nothing is sent. The events and webhooks still fire."
})

res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) }
puts res.code, res.body
```

**PHP**

```php
<?php

require "vendor/autoload.php";

$client = new GuzzleHttp\Client(["base_uri" => "https://api.avelto.dev"]);

$res = $client->request("POST", "/v1/emails", [
    "headers" => [
        "Authorization" => "Bearer " . getenv("AVELTO_API_KEY"),
    ],
    "json" => [
        "from" => "Acme <hello@mail.acme.com>",
        "to" => "jane@example.com",
        "subject" => "Password reset",
        "text" => "Nothing is sent. The events and webhooks still fire."
    ],
]);

echo $res->getStatusCode(), "\n", $res->getBody();
```

**C#**

```csharp
using System.Net.Http.Headers;
using System.Net.Http.Json;

var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("AVELTO_API_KEY"));

var res = await client.PostAsJsonAsync("https://api.avelto.dev/v1/emails", new
{
    from = "Acme <hello@mail.acme.com>",
    to = "jane@example.com",
    subject = "Password reset",
    text = "Nothing is sent. The events and webhooks still fire."
});
Console.WriteLine((int)res.StatusCode);
Console.WriteLine(await res.Content.ReadAsStringAsync());
```

Test emails are free, do not count towards your monthly limit, and show up in the dashboard and in `GET /v1/emails` with `mode: "test"`.

### A test key only ever sees test data

The mode is a boundary, not a filter on a shared pile. A test key reads test emails and
nothing else:

- `GET /v1/emails` returns test emails. `?mode=live` does not change that.
- `GET /v1/emails/:id` returns `404` for a live email, as do its events and its webhook
  deliveries. It is the same `404` you get for an id that does not exist.
- A few things touch live sending rather than live data, and need a live key:
  `DELETE /v1/domains/:id`, adding or removing a suppression, `GET /v1/account/export`
  and `POST /v1/recipients/erase`. These return `403 forbidden` with a message saying so.

You can still *read* your suppression list with a test key, because a test send is
checked against it like any other — if a test send comes back `recipient_suppressed`, you
need to be able to see why.

A live key reads both modes: it defaults to live, and `?mode=test` shows test emails.

The practical upshot is that a test key is safe to put somewhere a live key is not — CI,
a shared staging environment, a colleague's machine. Losing one costs you nothing but the
key.

## The sandbox domain

Every account can send from `sandbox.avelto.dev` before it has verified a domain. Any local part works, so `you@sandbox.avelto.dev` is a valid `from`. Two rules apply:

- Sends to the simulator addresses below are always allowed.
- Sends to anyone else are allowed only when every other recipient across `to`, `cc` and `bcc` is your account's owner email, and that email is verified. Simulator addresses can be mixed in.

Anything else returns `403 sandbox_recipient_not_allowed`:

```json
{
  "error": {
    "code": "sandbox_recipient_not_allowed",
    "message": "The sandbox sender @sandbox.avelto.dev only delivers to the account owner (you@example.com). Verify a domain to send to anyone.",
    "details": { "recipients_not_allowed": ["jane@example.com"] }
  }
}
```

[Verify a domain](/docs/domains) to send to anyone.

## Simulator recipients

Three addresses on the sandbox domain produce a fixed outcome. They work with live keys as well as test keys, and they never add anything to your suppression list.

| Recipient | Outcome |
| --- | --- |
| `delivered@sandbox.avelto.dev` | `email.delivered` |
| `bounced@sandbox.avelto.dev` | `email.bounced`, a hard bounce |
| `complained@sandbox.avelto.dev` | `email.complained` |

**curl**

```bash
curl -X POST https://api.avelto.dev/v1/emails \
  -H "Authorization: Bearer av_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "from": "you@sandbox.avelto.dev",
    "to": "bounced@sandbox.avelto.dev",
    "subject": "Bounce test",
    "text": "This one bounces."
  }'
```

**Node**

```ts
const { id } = await avelto.emails.send({
  from: "you@sandbox.avelto.dev",
  to: "bounced@sandbox.avelto.dev",
  subject: "Bounce test",
  text: "This one bounces.",
});
```

**Python**

```python
import os, requests

r = requests.post(
    "https://api.avelto.dev/v1/emails",
    headers={"Authorization": f"Bearer {os.environ['AVELTO_API_KEY']}"},
    json={
      "from": "you@sandbox.avelto.dev",
      "to": "bounced@sandbox.avelto.dev",
      "subject": "Bounce test",
      "text": "This one bounces."
    },
)
r.raise_for_status()
print(r.json())
```

**Go**

```go
package main

import (
	"bytes"
	"fmt"
	"net/http"
	"os"
)

func main() {
	body := []byte(`{"from":"you@sandbox.avelto.dev","to":"bounced@sandbox.avelto.dev","subject":"Bounce test","text":"This one bounces."}`)
	req, _ := http.NewRequest("POST", "https://api.avelto.dev/v1/emails", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+os.Getenv("AVELTO_API_KEY"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	fmt.Println(res.Status)
}
```

**Ruby**

```ruby
require "net/http"
require "json"

uri = URI("https://api.avelto.dev/v1/emails")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{ENV["AVELTO_API_KEY"]}"
req["Content-Type"] = "application/json"
req.body = JSON.generate({
  from: "you@sandbox.avelto.dev",
  to: "bounced@sandbox.avelto.dev",
  subject: "Bounce test",
  text: "This one bounces."
})

res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(req) }
puts res.code, res.body
```

**PHP**

```php
<?php

require "vendor/autoload.php";

$client = new GuzzleHttp\Client(["base_uri" => "https://api.avelto.dev"]);

$res = $client->request("POST", "/v1/emails", [
    "headers" => [
        "Authorization" => "Bearer " . getenv("AVELTO_API_KEY"),
    ],
    "json" => [
        "from" => "you@sandbox.avelto.dev",
        "to" => "bounced@sandbox.avelto.dev",
        "subject" => "Bounce test",
        "text" => "This one bounces."
    ],
]);

echo $res->getStatusCode(), "\n", $res->getBody();
```

**C#**

```csharp
using System.Net.Http.Headers;
using System.Net.Http.Json;

var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("AVELTO_API_KEY"));

var res = await client.PostAsJsonAsync("https://api.avelto.dev/v1/emails", new
{
    from = "you@sandbox.avelto.dev",
    to = "bounced@sandbox.avelto.dev",
    subject = "Bounce test",
    text = "This one bounces."
});
Console.WriteLine((int)res.StatusCode);
Console.WriteLine(await res.Content.ReadAsStringAsync());
```

Use them to exercise your webhook handler for every path before real traffic arrives.

## What test mode does not cover

- Rendering in a real mail client. Send a live email to your own account address from the sandbox domain for that.
- Provider throttling or deferrals. Test sends always complete in about two seconds.

---

Rendered page: https://avelto.dev/docs/test-mode
