# Domains

To send from your own address, add its domain, publish the DNS records you get back, and wait for verification. Until then you can send from your sandbox domain, `sandbox.avelto.dev`, to your own account email and to the simulator addresses.

## Add a domain

Use a subdomain such as `mail.acme.com` rather than the root domain. It keeps your website's DNS untouched and keeps sending reputation separate.

**curl**

```bash
curl -X POST https://api-staging.avelto.dev/v1/domains \
  -H "Authorization: Bearer av_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "mail.acme.com"
  }'
```

**Node**

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

const avelto = new Avelto(process.env.AVELTO_API_KEY!);

const domain = await avelto.domains.create({ name: "mail.acme.com" });
console.log(domain.dns_records);
```

**Python**

```python
import os, requests

r = requests.post(
    "https://api-staging.avelto.dev/v1/domains",
    headers={"Authorization": f"Bearer {os.environ['AVELTO_API_KEY']}"},
    json={
      "name": "mail.acme.com"
    },
)
r.raise_for_status()
print(r.json())
```

**Go**

```go
package main

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

func main() {
	body := []byte(`{"name":"mail.acme.com"}`)
	req, _ := http.NewRequest("POST", "https://api-staging.avelto.dev/v1/domains", 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-staging.avelto.dev/v1/domains")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{ENV["AVELTO_API_KEY"]}"
req["Content-Type"] = "application/json"
req.body = JSON.generate({
  name: "mail.acme.com"
})

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-staging.avelto.dev"]);

$res = $client->request("POST", "/v1/domains", [
    "headers" => [
        "Authorization" => "Bearer " . getenv("AVELTO_API_KEY"),
    ],
    "json" => [
        "name" => "mail.acme.com"
    ],
]);

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-staging.avelto.dev/v1/domains", new
{
    name = "mail.acme.com"
});
Console.WriteLine((int)res.StatusCode);
Console.WriteLine(await res.Content.ReadAsStringAsync());
```

```json
{
  "id": "1d3f2b7e-0d4a-4e5b-8d0c-6a7e9f1b2c3d",
  "name": "mail.acme.com",
  "region": "eu-north-1",
  "status": "pending",
  "dns_records": [
    {
      "type": "CNAME",
      "name": "k7x2p4qz._domainkey.mail.acme.com",
      "value": "k7x2p4qz.dkim.amazonses.com",
      "purpose": "dkim"
    },
    {
      "type": "CNAME",
      "name": "m3r9t1vb._domainkey.mail.acme.com",
      "value": "m3r9t1vb.dkim.amazonses.com",
      "purpose": "dkim"
    },
    {
      "type": "CNAME",
      "name": "w5n8c6hd._domainkey.mail.acme.com",
      "value": "w5n8c6hd.dkim.amazonses.com",
      "purpose": "dkim"
    },
    {
      "type": "TXT",
      "name": "mail.acme.com",
      "value": "v=spf1 include:spf.staging.avelto.dev ~all",
      "purpose": "spf"
    },
    {
      "type": "TXT",
      "name": "_dmarc.mail.acme.com",
      "value": "v=DMARC1; p=none;",
      "purpose": "dmarc"
    }
  ],
  "health": "unknown",
  "health_changed_at": null,
  "dns_checks": null,
  "dns_checked_at": null,
  "created_at": "2026-09-17T10:00:00.000Z"
}
```

The domain starts as `pending`. `409 conflict` means the name is already added, on your account or on another one.

## Publish the records

Five records, all at your DNS provider:

| Type | Name | Value | Purpose |
| --- | --- | --- | --- |
| CNAME | `<token>._domainkey.mail.acme.com` | `<token>.dkim.amazonses.com` | DKIM (three of these) |
| TXT | `mail.acme.com` | `v=spf1 include:spf.staging.avelto.dev ~all` | SPF |
| TXT | `_dmarc.mail.acme.com` | `v=DMARC1; p=none;` | DMARC |

Verification checks the three DKIM CNAMEs. SPF and DMARC are strongly recommended and improve delivery, but the domain verifies without them. If you already publish an SPF record, add `include:spf.staging.avelto.dev` to it instead of creating a second one. That one include covers our sending infrastructure, and it stays valid if the infrastructure behind it changes.

> **Existing DMARC policy.** If `_dmarc` already exists on the domain, keep your policy. The record we return is a permissive starting point for domains that have none.

## Check verification

Until the domain is verified, `GET /v1/domains/:id` re-checks verification on every call and updates `status`. Poll it after publishing; `status` becomes `verified` once the CNAMEs resolve, or `failed` if verification is abandoned or the name is verified on another account.

**curl**

```bash
curl https://api-staging.avelto.dev/v1/domains/1d3f2b7e-0d4a-4e5b-8d0c-6a7e9f1b2c3d \
  -H "Authorization: Bearer av_live_..."
```

**Node**

```ts
// Publish the records, then poll. domains.get re-checks verification on every call until the domain is verified.
let status = "pending";
while (status === "pending") {
  await new Promise((r) => setTimeout(r, 30_000));
  status = (await avelto.domains.get("1d3f2b7e-0d4a-4e5b-8d0c-6a7e9f1b2c3d")).status;
}
console.log(status); // "verified" or "failed"
```

**Python**

```python
import os, requests

r = requests.get(
    "https://api-staging.avelto.dev/v1/domains/1d3f2b7e-0d4a-4e5b-8d0c-6a7e9f1b2c3d",
    headers={"Authorization": f"Bearer {os.environ['AVELTO_API_KEY']}"},
)
r.raise_for_status()
print(r.json())
```

**Go**

```go
package main

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

func main() {
	req, _ := http.NewRequest("GET", "https://api-staging.avelto.dev/v1/domains/1d3f2b7e-0d4a-4e5b-8d0c-6a7e9f1b2c3d", nil)
	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-staging.avelto.dev/v1/domains/1d3f2b7e-0d4a-4e5b-8d0c-6a7e9f1b2c3d")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{ENV["AVELTO_API_KEY"]}"

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-staging.avelto.dev"]);

$res = $client->request("GET", "/v1/domains/1d3f2b7e-0d4a-4e5b-8d0c-6a7e9f1b2c3d", [
    "headers" => [
        "Authorization" => "Bearer " . getenv("AVELTO_API_KEY"),
    ],
]);

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.GetAsync("https://api-staging.avelto.dev/v1/domains/1d3f2b7e-0d4a-4e5b-8d0c-6a7e9f1b2c3d");
Console.WriteLine((int)res.StatusCode);
Console.WriteLine(await res.Content.ReadAsStringAsync());
```

DNS changes usually propagate in minutes, sometimes up to a few hours depending on your provider's TTLs.

## Send

Once the domain is `verified`, any `from` address on it works: `hello@mail.acme.com`, `Acme <billing@mail.acme.com>`, and so on. Sending from an unverified or unknown domain returns `403 domain_not_verified`.

## Remove a domain

**curl**

```bash
curl -X DELETE https://api-staging.avelto.dev/v1/domains/1d3f2b7e-0d4a-4e5b-8d0c-6a7e9f1b2c3d \
  -H "Authorization: Bearer av_live_..."
```

**Node**

```ts
await avelto.domains.delete("1d3f2b7e-0d4a-4e5b-8d0c-6a7e9f1b2c3d");
```

**Python**

```python
import os, requests

r = requests.delete(
    "https://api-staging.avelto.dev/v1/domains/1d3f2b7e-0d4a-4e5b-8d0c-6a7e9f1b2c3d",
    headers={"Authorization": f"Bearer {os.environ['AVELTO_API_KEY']}"},
)
r.raise_for_status()
```

**Go**

```go
package main

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

func main() {
	req, _ := http.NewRequest("DELETE", "https://api-staging.avelto.dev/v1/domains/1d3f2b7e-0d4a-4e5b-8d0c-6a7e9f1b2c3d", nil)
	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-staging.avelto.dev/v1/domains/1d3f2b7e-0d4a-4e5b-8d0c-6a7e9f1b2c3d")
req = Net::HTTP::Delete.new(uri)
req["Authorization"] = "Bearer #{ENV["AVELTO_API_KEY"]}"

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-staging.avelto.dev"]);

$res = $client->request("DELETE", "/v1/domains/1d3f2b7e-0d4a-4e5b-8d0c-6a7e9f1b2c3d", [
    "headers" => [
        "Authorization" => "Bearer " . getenv("AVELTO_API_KEY"),
    ],
]);

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.DeleteAsync("https://api-staging.avelto.dev/v1/domains/1d3f2b7e-0d4a-4e5b-8d0c-6a7e9f1b2c3d");
Console.WriteLine((int)res.StatusCode);
Console.WriteLine(await res.Content.ReadAsStringAsync());
```

Returns `204`. Emails already sent from it keep their records.

## Domain health

Verification is a moment; staying verified is not. A DNS provider migration, an expired zone edit or a tidy-up of "unused" records can quietly undo any of this months later, and the first sign is usually mail landing in spam.

So we keep looking. Every night we re-resolve each verified domain's DKIM, SPF and DMARC records and compare them with what we expect. The domains page in your dashboard shows each record's state side by side: what we expect, what actually resolved, and whether that is a match, a mismatch or nothing at all.

If a DKIM or SPF record stops matching, the domain is marked **action needed**: the domain's row in your dashboard shows that pill and the DNS health table, an entry goes into your audit log, and we email you if you have domain health alerts switched on. A missing DMARC record never triggers this on its own, because running without one is a choice rather than a fault.

You can re-check a domain yourself from the dashboard at any time. That button is limited to once a minute per domain so an impatient refresh cannot hammer your DNS provider.

## Limits

- Free includes one domain; Pro and above have no limit. Adding a domain past the limit returns `403 plan_limit`.
- New accounts can add up to 5 domains in their first 7 days.

See [Plans and limits](/docs/plans-and-limits) for what each plan includes.

---

Rendered page: https://staging.avelto.dev/docs/domains
