> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gavai.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Attach a custom domain

> Serve your workspace at your own domain via DNS verification.

export const ErrorTable = ({errors}) => <div className="gv-error-table" role="table" aria-label="Errors and fixes">
    <div className="gv-error-table__head" role="row">
      <span className="gv-error-table__col-head" role="columnheader">Error</span>
      <span className="gv-error-table__col-head" role="columnheader">Cause</span>
      <span className="gv-error-table__col-head" role="columnheader">Fix</span>
    </div>
    {errors.map((err, i) => <div className="gv-error-table__row" role="row" key={i}>
        <code className="gv-error-table__code" role="cell">{err.code}</code>
        <div className="gv-error-table__cause" role="cell">{err.cause}</div>
        <div className="gv-error-table__fix" role="cell">{err.fix}</div>
      </div>)}
  </div>;

export const ExpectedOutput = ({caption = "Expected output", children}) => <div className="gv-output">
    <div className="gv-output__label">
      <svg className="gv-output__icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
        <polyline points="15 10 20 15 15 20" />
        <path d="M4 4v7a4 4 0 0 0 4 4h12" />
      </svg>
      <span>{caption}</span>
    </div>
    <div className="gv-output__body">{children}</div>
  </div>;

export const PageMeta = ({audience, readingTime, prereqs, level}) => {
  const items = [audience && ({
    key: "audience",
    label: "AUDIENCE",
    value: audience
  }), readingTime && ({
    key: "time",
    label: "READ",
    value: readingTime
  }), level && ({
    key: "level",
    label: "LEVEL",
    value: level
  }), prereqs && prereqs.length > 0 && ({
    key: "prereqs",
    label: "PREREQS",
    value: prereqs.join(" · ")
  })].filter(Boolean);
  if (items.length === 0) return null;
  return <div className="gv-page-meta" role="group" aria-label="Page metadata">
      {items.map(item => <div className="gv-page-meta__item" key={item.key}>
          <span className="gv-page-meta__label">{item.label}</span>
          <span className="gv-page-meta__value">{item.value}</span>
        </div>)}
    </div>;
};

export const BrandCard = ({eyebrow, title, href, icon, badge, cta, featured = false, horizontal = false, children}) => {
  const classes = ["gv-card", featured && "gv-card--featured", horizontal && "gv-card--horizontal"].filter(Boolean).join(" ");
  const inner = <>
      {icon && <span className="gv-card__icon" aria-hidden="true">
          <Icon icon={icon} />
        </span>}
      <div className="gv-card__main">
        {(eyebrow || badge) && <div className="gv-card__meta">
            {eyebrow && <Eyebrow label={eyebrow} tone="muted" />}
            {badge && <span className="gv-card__badge">{badge}</span>}
          </div>}
        <h3 className="gv-card__title">{title}</h3>
        {children && <div className="gv-card__body">{children}</div>}
        {cta && <span className="gv-card__cta">
            {cta}
            <svg className="gv-card__cta-arrow" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <line x1="7" y1="17" x2="17" y2="7" />
              <polyline points="7 7 17 7 17 17" />
            </svg>
          </span>}
      </div>
    </>;
  return href ? <a className={classes} href={href}>
      {inner}
    </a> : <div className={classes}>{inner}</div>;
};

export const NumberedStep = ({n, title, children}) => <li className="gv-step">
    <span className="gv-step__index">{n}</span>
    <div className="gv-step__body">
      <h3 className="gv-step__title">{title}</h3>
      <div className="gv-step__content">{children}</div>
    </div>
  </li>;

export const NumberedSteps = ({children}) => <ol className="gv-steps">{children}</ol>;

export const ArrowItem = ({children}) => <li className="gv-arrow-item">
    <span className="gv-arrow-item__arrow" aria-hidden="true">{"↳︎"}</span>
    <span className="gv-arrow-item__content">{children}</span>
  </li>;

export const ArrowList = ({children}) => <ul className="gv-arrow-list">{children}</ul>;

export const Eyebrow = ({label, tone = "default"}) => <div className={`gv-eyebrow gv-eyebrow--${tone}`}>
    <span className="gv-eyebrow__bar" aria-hidden="true" />
    <span className="gv-eyebrow__label">{label}</span>
  </div>;

<Eyebrow label="GUIDE" />

<PageMeta audience="Developers / Ops" readingTime="15 min" level="Intermediate" prereqs={["API token with domains:read + domains:write", "Domain you control", "DNS provider access"]} />

By the end of this guide your workspace is reachable at your own hostname — `app.acme.com`, say — instead of the default `acme.gavai.app`. You attach the hostname, read the DNS values gavAI generates, set the records with your DNS provider, trigger verification, and poll for the confirmed status. TLS is provisioned automatically once verification completes.

## Prerequisites

<ArrowList>
  <ArrowItem>An API token with the `domains:write` and `domains:read` scopes on the workspace.</ArrowItem>
  <ArrowItem>A domain you own and control.</ArrowItem>
  <ArrowItem>Access to that domain's DNS records — via your registrar or a DNS provider.</ArrowItem>
</ArrowList>

## Steps

<NumberedSteps>
  <NumberedStep title="Attach the hostname">
    Tell gavAI which hostname to serve. The response contains the domain `id` you will use in every subsequent call.

    <CodeGroup>
      ```bash curl theme={null}
      curl -X POST https://api.gavai.app/v1/workspaces/acme/domains \
        -H "Authorization: Bearer $GAVAI_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{"hostname": "app.acme.com"}'
      ```

      ```ts SDK theme={null}
      import { Gavai } from "@gavai/sdk";

      const gv = new Gavai({ apiKey: process.env.GAVAI_API_KEY });

      const domain = await gv.domains.attach({
        workspace: "acme",
        hostname: "app.acme.com",
      });

      console.log("Domain id:", domain.id);
      ```
    </CodeGroup>

    <ExpectedOutput caption="Response shape">
      ```json theme={null}
      {
        "id": "dom_01H8X3K2YQZ4V7N9P5R6S7T8U9",
        "hostname": "app.acme.com",
        "status": "pending",
        "verification": {
          "type": "...",
          "name": "...",
          "value": "..."
        }
      }
      ```
    </ExpectedOutput>
  </NumberedStep>

  <NumberedStep title="Read the verification record">
    The attach response (and a subsequent `GET /v1/workspaces/acme/domains/dom_01H8X3K2YQZ4V7N9P5R6S7T8U9`) returns the `verification` object — the record your DNS provider needs to prove you control the hostname and that gavAI should serve it.

    <Note>
      Whether the verification record is a CNAME or a TXT, and the exact shape of `name` and `value`, is not enumerated in the public source. Confirm with the platform team before publishing the record values into your provider's UI.
    </Note>
  </NumberedStep>

  <NumberedStep title="Set the DNS records">
    Log in to your DNS provider and add the verification record from step 2. You also need to point the hostname itself at gavAI by adding a **CNAME** for `app.acme.com` pointing at gavAI's canonical serving hostname.

    <Note>
      gavAI's canonical CNAME target (for example `cname.gavai.app`) is not enumerated in the public source. Confirm the exact value with the platform team.
    </Note>

    DNS propagation usually completes in minutes but can take up to 48 hours. Confirm with `dig` or a tool like `dnschecker.org` before triggering verification.
  </NumberedStep>

  <NumberedStep title="Trigger verification">
    Once the records are in place, ask gavAI to check them:

    <CodeGroup>
      ```bash curl theme={null}
      curl -X POST https://api.gavai.app/v1/workspaces/acme/domains/dom_01H8X3K2YQZ4V7N9P5R6S7T8U9/verify \
        -H "Authorization: Bearer $GAVAI_API_KEY"
      ```

      ```ts SDK theme={null}
      await gv.domains.verify({
        workspace: "acme",
        id: domain.id,
      });
      ```
    </CodeGroup>

    This triggers an immediate DNS lookup from gavAI. If the records are already propagated, the status moves to `verified` within seconds.
  </NumberedStep>

  <NumberedStep title="Poll for status">
    Check the domain status until it reaches `verified`:

    <CodeGroup>
      ```bash curl theme={null}
      curl https://api.gavai.app/v1/workspaces/acme/domains/dom_01H8X3K2YQZ4V7N9P5R6S7T8U9/status \
        -H "Authorization: Bearer $GAVAI_API_KEY"
      ```

      ```ts SDK theme={null}
      const status = await gv.domains.getStatus({
        workspace: "acme",
        id: domain.id,
      });

      console.log(status.status);
      ```
    </CodeGroup>

    Expected output cycles through:

    | Status     | Meaning                                                                                  |
    | ---------- | ---------------------------------------------------------------------------------------- |
    | `pending`  | Verification not confirmed yet. DNS may still be propagating.                            |
    | `verified` | Domain is live. Traffic routes to your workspace. TLS is active.                         |
    | `failed`   | Verification failed. Confirm DNS values match step 2 exactly, then call `/verify` again. |

    Poll every 30–60 seconds. Propagation typically completes in minutes but can take up to 48 hours.

    <Tip>
      TLS certificates are provisioned and renewed automatically once the domain reaches `verified`. You do not manage certificates yourself.
    </Tip>
  </NumberedStep>
</NumberedSteps>

## Troubleshooting

<ErrorTable
  errors={[
{
  code: "status: failed",
  cause: "DNS values do not match what gavAI generated.",
  fix: "Re-fetch the domain, copy the verification values byte-for-byte into your DNS provider, wait for propagation, and call /verify again.",
},
{
  code: "Stuck on pending for hours",
  cause: "DNS has not propagated, or your provider is caching aggressively.",
  fix: "Check with dig app.acme.com from a different network. If dig returns the right value, retry /verify.",
},
{
  code: "insufficient_scope",
  cause: "The API token lacks domains:write.",
  fix: "Generate a new token with both domains:write and domains:read scopes.",
},
{
  code: "TLS warning on the live site",
  cause: "Certificate has not provisioned yet after verification.",
  fix: "Allow a few minutes after verified for the cert to issue and propagate to edges.",
},
]}
/>

## What you built

You attached a custom hostname to the workspace, set the verification and routing DNS records, verified through the API, and confirmed the domain is live. The workspace is reachable at your own domain instead of `acme.gavai.app`, with TLS handled automatically.

## Next steps

<Columns cols={2}>
  <BrandCard title="Domains API reference" href="/api-reference/resources/domains" cta="Read reference">
    Full endpoint reference for listing, attaching, verifying, and deleting custom domains.
  </BrandCard>

  <BrandCard title="Multi-tenant isolation" href="/security/multi-tenant-isolation" cta="Read more">
    How gavAI keeps each workspace's data and traffic isolated, even across custom domains.
  </BrandCard>

  <BrandCard title="End-user authentication" href="/guides/end-user-auth" cta="Read guide">
    Gate blocks by sign-in state and user role once your domain is live.
  </BrandCard>
</Columns>
