---
title: Domains
description: Add a sending domain, publish the DNS records, verify it, diagnose the one record that won't resolve, and cover every subdomain at once with a wildcard.
sidebar:
  icon: globe
---

Adding a domain makes every address on it usable for sending, and — if you publish the MX record — for receiving too. Adding one returns the DNS records to publish; verification checks that they resolve.

The domain does not have to be yours. A multi-tenant product adds a customer's domain, shows them the returned records to publish at their registrar, and polls `verify` or waits for [`domain.verified`](/webhooks/events/domain-verified). The only requirement is that _someone_ can publish DNS for it. Where nobody can, connect a [mailbox](/guides/mailboxes) instead.

## Your provided domain

Every workspace comes with one domain: `<slug>.aiinbx.app`, where `<slug>` is the workspace's URL handle — like a `.vercel.app` domain. It works right away, every address on it sends and receives, and there is no DNS to set up. Handy for a side project, or for getting going before you bring a domain of your own.

```ts
const domains = await aiinbx.domains.list().all()
const provided = domains.find((domain) => domain.provided)

await aiinbx.emails.send({
  from: `hello@${provided.name}`,
  to: "someone@example.com",
  subject: "Sent before a single DNS record",
  text: "It works out of the box.",
})
```

It is listed with `provided: true`, has no `records`, and cannot be deleted — it goes when the workspace does. Renaming the workspace does not rename it. A name under `aiinbx.app` cannot be added by hand, and fails with [`reserved_domain`](/reference/errors#reserved_domain).

## Adding a domain

```ts TypeScript
const domain = await aiinbx.domains.create({
  name: "example.com",
  region: "eu-central-1",
})

for (const record of domain.records ?? []) {
  console.log(
    record.purpose,
    record.type,
    record.name,
    record.value,
    record.ttl
  )
}
```

```python Python
domain = client.domains.create(name="example.com", region="eu-central-1")

for record in domain.get("records", []):
    print(record["purpose"], record["type"], record["name"], record["value"])
```

```bash curl
curl https://api.aiinbx.com/api/v2/domains \
  -H "Authorization: Bearer $AI_INBX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "example.com", "region": "eu-central-1" }'
```

Creating, updating, and deleting domains needs a `full` [scope](/authentication#scopes) key.

### Regions

`region` selects the regional mail infrastructure for this domain: `eu-central-1` (the default) or `us-east-1`. It is fixed at creation — moving a domain between regions means deleting and re-adding it, and republishing DNS. Do not treat this setting alone as a guarantee that all application data, logs, or subprocessors stay in that region. Confirm your residency requirements separately before choosing a region.

Adding the same identity twice in one workspace fails with `409` [`domain_taken`](/reference/errors#domain_taken). That includes the apex and wildcard forms of one name, such as `example.com` and `*.example.com`. See [Moving a domain between workspaces](#moving-a-domain-between-workspaces).

### Spaces

`space_id` puts the domain in a [space](/guides/spaces), and with it every email and thread that goes through the domain from then on. Omit it and the domain belongs to the workspace itself, `space_id: null`. There is no move afterwards; a space is decided when the domain is created.

## The records

`records` lists everything to publish, each with the `purpose` it serves and a `state` of `pending`, `verified`, or `missing`.

| Purpose       | Type    | What it does                                                                                         |
| ------------- | ------- | ---------------------------------------------------------------------------------------------------- |
| `SPF`         | `TXT`   | Authorizes AI Inbx to send for the domain.                                                           |
| `DKIM`        | `TXT`   | Publishes the keys that sign your outgoing mail.                                                     |
| `DMARC`       | `TXT`   | Tells receivers what to do when SPF or DKIM fails, and where to send reports.                        |
| `RETURN_PATH` | `MX`    | Routes bounces and complaints back so delivery status is accurate.                                   |
| `INBOUND`     | `MX`    | Delivers mail _to_ the domain into AI Inbx. Only needed if you want to [receive](/guides/receiving). |

:::warning[The MX record is a commitment]
Publishing the `INBOUND` MX record routes **all** mail for the domain to AI Inbx. If the domain already receives mail somewhere — a Google Workspace or Microsoft 365 tenant, say — that takes the existing inboxes away. Use a dedicated subdomain (`mail.example.com`) for inbound instead, or connect the existing mailbox over OAuth. See [Mailboxes](/guides/mailboxes).

This is the single most important thing to get right when you're onboarding someone else's domain: they will almost always already be receiving mail on it.
:::

Publish the exact names, types, and values returned by the API. Check existing records before making changes, especially an existing DKIM selector or DMARC policy. Sending requires DKIM verification; the return-path, SPF, and DMARC records configure authentication and bounce handling. Receiving separately requires the inbound MX record.

## Verifying

Publishing DNS doesn't tell us anything by itself, so ask for a check:

```ts TypeScript
const domain = await aiinbx.domains.verify("dom_...")

if (domain.verified_at) {
  console.log("ready to send")
} else {
  const missing = domain.records?.filter((r) => r.state !== "verified")
  console.log(
    "still waiting on",
    missing?.map((r) => r.purpose)
  )
}
```

```python Python
domain = client.domains.verify("dom_...")
print(domain["verified_at"])
```

```bash curl
curl -X POST https://api.aiinbx.com/api/v2/domains/dom_.../verify \
  -H "Authorization: Bearer $AI_INBX_API_KEY"
```

`verified_at` is `null` until the sending identity’s DKIM verification succeeds. It does not certify inbound MX, SPF, or DMARC configuration. Each record's `last_checked_at` tells you when it was last looked at. Propagation is usually minutes and occasionally hours — poll with a sane interval rather than in a tight loop.

Sending from an unverified domain fails with `409` [`domain_unverified`](/reference/errors#domain_unverified).

## Diagnostics

When a record won't verify and you can't see why, `diagnostics` reads the live zone and reports what it finds. It's a read-only check — nothing is stored, so it always reflects DNS as it is right now.

```ts TypeScript
const { data } = await aiinbx.domains.diagnostics("dom_...")

for (const finding of data) {
  console.log(`[${finding.severity}] ${finding.title}`)
  console.log(finding.detail)
  console.log("fix:", finding.fix)
}
```

```python Python
findings = client.domains.diagnostics("dom_...")["data"]

for finding in findings:
    print(finding["severity"], finding["title"], finding["fix"])
```

```bash curl
curl https://api.aiinbx.com/api/v2/domains/dom_.../diagnostics \
  -H "Authorization: Bearer $AI_INBX_API_KEY"
```

Each finding carries a `severity`, the `purpose` it concerns, a `detail` explaining what's wrong, a `fix` describing what to change, and `evidence` — the actual records read from the zone.

| Prop | Type | Default | Description |
| - | - | - | - |
| `breaking` | `severity` | - | Mail will fail or be rejected. Fix before sending. |
| `risk?` | `severity` | - | Deliverability is likely to suffer — a too-permissive SPF policy, a DMARC record set to none. |
| `tip?` | `severity` | - | A hardening suggestion. Nothing is broken. |

Findings also carry `when`: `adding` applies while you're setting the domain up, `added` to one already live, and `both` either way.

The classic breaking finding is two SPF records on the same name — receivers treat that as a permanent error and ignore both. Diagnostics names the offending records so you know which to merge.

## Wildcards and subdomains

A wildcard — `*.saas.com` — is one domain that covers every subdomain. Publish its records once and `anything.saas.com` can send and receive, without touching DNS again. It is how a platform hands each customer an address of their own.

```ts
const wildcard = await aiinbx.domains.create({ name: "*.saas.com" })
```

The records go in the `saas.com` zone, but the MX record sits at the wildcard rather than the apex — `saas.com` keeps whatever mail setup it already has, and only its subdomains route to AI Inbx:

| Purpose       | Type  | Name                       |
| ------------- | ----- | -------------------------- |
| `DKIM`        | `TXT` | `aibx._domainkey.saas.com` |
| `INBOUND`     | `MX`  | `*.saas.com`               |
| `RETURN_PATH` | `MX`  | `bounces.saas.com`         |
| `SPF`         | `TXT` | `bounces.saas.com`         |
| `DMARC`       | `TXT` | `_dmarc.saas.com`          |

`saas.com` and `*.saas.com` are the same identity on the mail provider, so only one of the two can exist here: adding the second fails with [`domain_taken`](/reference/errors#domain_taken). Pick the apex if you send from `saas.com` itself; pick the wildcard if the addresses are `<customer>.saas.com`.

:::warning[An existing wildcard CNAME]
A name that holds a `CNAME` may hold nothing else. If `*.saas.com` is already a wildcard `CNAME` — customer sites on `<customer>.saas.com`, typically — the MX record cannot be published beside it. Use a level down, `*.mail.saas.com`, and give customers `<customer>.mail.saas.com`. `diagnostics` reports a zone that already answers for unset names as a `wildcard-zone` finding, which is the sign to look for.
:::

### Subdomains

Once a wildcard is yours, a name one label under it — `acme.saas.com` under `*.saas.com` — is created as a **subdomain** of it:

```ts TypeScript
const domain = await aiinbx.domains.create({
  name: "acme.saas.com",
  space_id: "spc_...",
})

console.log(domain.parent_id) // the wildcard's id
console.log(domain.records) // []
```

```python Python
domain = client.domains.create(name="acme.saas.com", space_id="spc_...")
print(domain["parent_id"], domain["verified_at"])
```

```bash curl
curl https://api.aiinbx.com/api/v2/domains \
  -H "Authorization: Bearer $AI_INBX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "acme.saas.com", "space_id": "spc_..." }'
```

A subdomain has no records of its own and nothing to verify — `verified_at` is set the moment the wildcard's is, and follows it. `parent_id` names the wildcard. `region` and the tracking flags are inherited. It is usually created in a [space](/guides/spaces), so that the customer's mail is theirs.

What is not a subdomain: `a.b.saas.com` is two labels down and is an ordinary domain with records of its own, and so is `acme.saas.com` in a workspace that has no `*.saas.com` yet.

One name is refused: `bounces.saas.com` is the wildcard's return path, and fails with `409` [`reserved_subdomain`](/reference/errors#reserved_subdomain).

### Deleting

A subdomain is a row: deleting it unpublishes nothing. Deleting a wildcard deletes every subdomain filed under it, in whatever [space](/guides/spaces) they sit — they are names under its identity and its records, and nothing is left to publish for them. Mail already sent or received is kept either way. Remove DNS records that belonged to the deleted connection after checking that they are no longer needed.

## Tracking settings

`PATCH /domains/{id}` toggles open and click tracking:

```ts
await aiinbx.domains.update("dom_...", {
  track_opens: false,
  track_clicks: true,
})
```

The values are returned on the domain as `tracking.opens` and `tracking.clicks`.

:::warning[Not yet enforced at send time]
These flags are stored and reported, but the send path does not currently read them — engagement tracking is applied to every send regardless of the per-domain setting. Treat them as a stated preference, not an enforced one, and don't rely on `track_opens: false` to suppress open tracking today.
:::

## Listing and deleting

```ts TypeScript
const domains = await aiinbx.domains.list().all()

await aiinbx.domains.delete("dom_...")
```

```python Python
for domain in client.domains.iter():
    print(domain["name"], domain["verified_at"])

client.domains.delete("dom_...")
```

Deleting a domain stops future sends from it. Remove the DNS records afterwards — an SPF or DKIM record pointing at a domain that no longer exists is dead weight in your zone. The [provided domain](#your-provided-domain) cannot be deleted: the call fails with [`domain_provided`](/reference/errors#domain_provided).

## Knowing when it's ready

Rather than polling, subscribe to [`domain.verified`](/webhooks/events/domain-verified):

```ts
if (event.type === "domain.verified") {
  console.log(event.data.name, "is ready in", event.data.region)
}
```

## Staying verified

Verification is not a one-off. Domains are rechecked in the background, and one whose DKIM record stops resolving loses `verified_at` again: sends from it fail with [`domain_unverified`](/reference/errors#domain_unverified) until the record is back, and [`domain.lost`](/webhooks/events/domain-lost) tells you it happened. Nothing else changes — the domain, its records and its mail stay where they were, and the next check that finds the key restores it.

Both transitions are also mailed to the workspace's owners and admins, batched per workspace: one mail lists everything that verified or was lost since the last one, lost first. Domains connected inside a [space](/guides/spaces) are never mailed — the webhook has them. Each admin sets their own level — Everything, Problems only, or Off — under Settings → Notifications, or from the link at the bottom of any of these mails.

## Moving a domain between workspaces

A domain name can be connected in multiple workspaces, but only the workspace whose DKIM key is currently published can send from it. Add the domain in the destination workspace, then publish the DNS records returned by that connection. The next verification check transfers sending to it and marks the previous connection as lost. Existing mail remains in the workspace that sent or received it.

Within one workspace, the apex and wildcard are one connection: `example.com` and `*.example.com` cannot both be added there. Where multiple workspaces need to send at the same time, give each one a distinct subdomain.
