# Totalum App Builder API — Custom Domains

This file is the complete reference for this area. The short guide for agents is https://www.totalum.app/totalum-api.md.

- Base URL: `https://builder-api.totalum.app/api/v1/vcaas`
- Authentication: header `api-key: tlm_sk_…` on every request, from a server only.
- Response envelope: `{ "errors": null | { "errorCode", "errorMessage", "errorDetails?" }, "data": … }`.


---

# Custom Domains

> Put your app on your own domain.

A published project answers at `<projectId>.totalum-project.com`. On the Professional and Enterprise plans it also answers at `<projectId>.webapp-project.com` — both addresses serve the site — and [Update Project](https://www.totalum.app/docs/api/projects#update-project) `{ "liveHostSuffix": "webapp-project.com" }` makes that one the main address (the one shown as `productionProjectUrl` and used for links). Add **one** custom domain per project: a subdomain (`app.example.com`) or a whole domain (`example.com` or `www.example.com`, served as `www.example.com`; the bare domain redirects to it once it points here, which needs an `ALIAS`/`ANAME` record or CNAME flattening at your DNS provider — otherwise add a URL redirect from `example.com` to `https://www.example.com` there). You create the DNS records at your DNS provider; SSL is issued automatically. A domain [bought on Totalum](https://www.totalum.app/docs/api/domain-registration) is attached with its own endpoint and needs no DNS work.

> **An address without "totalum" in it.** For a white-label app, use either option:
> - **`webapp-project.com`** (Professional and Enterprise): set `liveHostSuffix` to `"webapp-project.com"`, and the main address, links, sitemap and preview use `<projectId>.webapp-project.com`. The `totalum-project.com` address keeps working too, so old links never break; it just isn't shown anywhere.
> - **Your own domain** (any paid plan): [add a custom domain](#add-custom-domain) or [buy one on Totalum](https://www.totalum.app/docs/api/domain-registration). It becomes the published address.
>
> Domains are set **per project**: each project can have its own custom domain and/or use `webapp-project.com`. One domain that replaces the address of every project of an account at once is not supported yet.

A custom domain costs 4 credits when added and 3 credits a month while active. Custom domains are included on paid plans.

## Add Custom Domain

```endpoint
method: PUT
path: /api/v1/vcaas/projects/:projectId/domain
cost: 4 credits (first domain)
```

Connect a domain you own. Publish the project first. Then create the records in `dnsRecordsToAdd` at your DNS provider and poll [Get Project](https://www.totalum.app/docs/api/projects#get-project) until `customDomain.status` and `customDomain.sslStatus` are `"active"` (usually minutes, up to a few hours for DNS). Calling it again with another hostname replaces the domain.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Body parameters**

| Field | Type | Required | Description |
|---|---|---|---|
| `hostname` | string | Yes | `app.example.com` or `example.com` |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.success` | boolean | `true` |
| `data.hostname` | string | The hostname that will serve the site |
| `data.status` | string | `"pending"` until the DNS records are found, then `"active"` |
| `data.dnsRecordsToAdd` | object[] | Records to create: `{ type: "CNAME" \| "TXT" \| "ALIAS", name, value }`; `name` is the full host name. For a whole domain the bare domain's `ALIAS` and `TXT` come last: optional, they make it redirect to `www.` |

**Example request**

```bash
curl -X PUT \
  -H "api-key: tlm_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"hostname":"book.barbershop.com"}' \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/domain
```

:::success Success · 200 OK
```json
{
  "errors": null,
  "data": {
    "success": true,
    "hostname": "book.barbershop.com",
    "status": "pending",
    "dnsRecordsToAdd": [
      { "type": "CNAME", "name": "book.barbershop.com", "value": "barber-bookings.totalum-project.com" },
      { "type": "TXT", "name": "_cf-custom-hostname.book.barbershop.com", "value": "4f6a2c9e-…" }
    ]
  }
}
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `MISSING_HOSTNAME` | 400 | `hostname` is missing |
| `VALIDATION_ERROR` | 400 | Not a valid domain name, or a Totalum host |
| `NO_DEPLOYMENT` | 404 | Publish the project first |
| `DOMAIN_ALREADY_ADDED` | 409 | This domain is already on the project |
| `DOMAIN_IN_USE` | 409 | The domain is connected to another project |
| `PROJECT_HAS_CUSTOM_DOMAIN` | 409 | The project uses a domain bought on Totalum; detach it first |
| `PLATFORM_CAPABILITY_QUOTA_REACHED` | 403 | Custom domains are not included on your plan |
| `INSUFFICIENT_CREDITS` | 402 | Not enough credits |

## Get Custom Domain

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/domain
cost: Free
```

The project's custom domain and the state of each of its addresses — the site and, for a whole domain, the bare domain that redirects to it. Statuses are checked again with Cloudflare while an address is still pending (at most once a minute), so poll this (or [Get Project](https://www.totalum.app/docs/api/projects#get-project), whose `customDomain` carries the same fields) until every `status` and `sslStatus` is `"active"`. New engine projects only. `data` is `null` when the project has no custom domain.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.hostname` | string | Where the site answers |
| `data.status` | string | That address: `"pending"` until its DNS records are found, then `"active"` |
| `data.sslStatus` | string | Its HTTPS certificate: `"active"` once issued |
| `data.domain` | string | The domain (`barbershop.io`) |
| `data.kind` | string | `"domain"` or `"subdomain"` |
| `data.canonicalHost` | string \| null | `"www"`, `"app"`, any label, or `"apex"`; `null` for a subdomain |
| `data.source` | string | `"purchase"` (bought on Totalum: no DNS work) or `"manual"` |
| `data.accountDomainId` | string \| undefined | The [bought domain](https://www.totalum.app/docs/api/domain-registration#get-domain), when `source` is `"purchase"` |
| `data.hostnames[]` | object[] | Each address: `{ hostname, role, status, sslStatus, dnsRecordsToAdd, verificationErrors }`; `role` is `"canonical"` (the site), `"alias"` (redirects to it with a 301, path and query kept) or `"sub"` |
| `data.dnsRecordsToAdd` | object[] | Every record still to create, as in [Add Custom Domain](#add-custom-domain) (empty for a bought domain: Totalum writes them) |
| `data.redirects` | object | `{ canonical, applyToDefaultHost, rules }` |

**Example request**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/domain
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "hostname": "app.barbershop.io", "status": "active", "sslStatus": "active", "domain": "barbershop.io", "kind": "domain", "canonicalHost": "app", "source": "purchase", "accountDomainId": "dom_01k6y3", "hostnames": [{ "hostname": "app.barbershop.io", "role": "canonical", "status": "active", "sslStatus": "active", "dnsRecordsToAdd": [], "verificationErrors": [] }, { "hostname": "barbershop.io", "role": "alias", "status": "active", "sslStatus": "active", "dnsRecordsToAdd": [], "verificationErrors": [] }], "dnsRecordsToAdd": [], "redirects": { "canonical": "app.barbershop.io", "applyToDefaultHost": false, "rules": [] } } }
```
:::

## Remove Custom Domain

```endpoint
method: DELETE
path: /api/v1/vcaas/projects/:projectId/domain
cost: Free
```

Disconnect the custom domain. The site keeps answering at `<projectId>.totalum-project.com`. Your DNS records are not touched. A domain [bought on Totalum](https://www.totalum.app/docs/api/domain-registration) is detached the same way: it stays yours and is not refunded, and it keeps renewing until you switch `autoRenew` off ([Detach Domain](https://www.totalum.app/docs/api/domain-registration#detach-domain)).

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `projectId` | string | The project id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.success` | boolean | `true` |
| `data.hostname` | string \| null | The domain removed |
| `data.message` | string | `"Custom domain removed"` |

**Example request**

```bash
curl -X DELETE -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/domain
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "success": true, "hostname": "book.barbershop.com", "message": "Custom domain removed" } }
```
:::
