# Domainee — full documentation > Domainee is a custom domains API for SaaS with a native MCP server, 20 domains and 100 GB free. This file inlines every Domainee guide and API reference page so an agent can answer questions and write working requests from a single fetch. The linked map version is at https://domainee.dev/llms.txt. Generated from the same source as https://domainee.dev/docs. ## Product summary - **Connect API** (`POST /v1/domains`): a user brings their own domain, Domainee returns a CNAME target and handles SSL issuance and renewal (Let's Encrypt/ACME) forever, with DNS health monitoring and webhooks. - **Provider-aware connect** (`GET /v1/domains/{id}/instructions`): Domainee fingerprints the DNS provider actually serving the customer's domain from its public NS records and returns setup steps written for that provider, including the record name its form expects (providers disagree about whether the apex is `@`, blank, or the full domain) and the quirks that silently break connections. Where the provider has enabled Domainee's Domain Connect templates, the same response carries a one-click apply URL: live at Cloudflare for all accounts since 2026-09-16 (also Glauca Digital), not available at GoDaddy, which is not onboarding new templates. Customers on Cloudflare or DigitalOcean can instead hand Domainee a scoped API token via `POST /v1/domains/{id}/dns/apply` and have the records written for them; the token is used once and never stored. - **Buy a Domain API** (`POST /v1/domain-purchases`): register domains in-app across 500+ TLDs through the customer's own Stripe account, at wholesale price plus a $1 flat fee; the end user is the legal registrant. Because Domainee is the registrar for these domains, `autoConnect` points them at the edge with no DNS step for the end user at all: no record to copy, no registrar panel, no propagation wait. Competing custom-domain providers are proxies rather than registrars and cannot do this. Connect writes only routing records, so existing MX, SPF, DKIM and DMARC survive. - **MCP server**: 12 workspace tools (with an API key) so AI agents (Claude, Cursor, and others) can connect, verify, and manage domains directly, plus 18 free diagnostic tools (SSL/DNS/WHOIS lookups) usable with no key. Domainee is the only custom domains API that is MCP-native. - **18 free public REST endpoints** under `https://api.domainee.dev/v1/tools/` with no key and no signup, documented in full below. Pricing: 20 custom domains and 100 GB bandwidth/month free forever (a card on file is needed to add domains, charged $0 until you pass the free tier). Then $0.20 per domain/month with graduated discounts down to $0.10, and $0.05/GB past 100 GB. Fully self-serve. ## Install the MCP server Hosted, stateless MCP endpoint: `https://mcp.domainee.dev/mcp`. The 18 free diagnostic tools work with no auth; the 12 workspace tools need a Bearer Domainee API key from /developers. - Claude Code: `claude mcp add --transport http domainee https://mcp.domainee.dev/mcp --header "Authorization: Bearer sk_live_YOUR_KEY"` - JSON config (Claude Desktop, Cursor, Windsurf, Continue, Cline): `{"mcpServers":{"domainee":{"url":"https://mcp.domainee.dev/mcp","headers":{"Authorization":"Bearer sk_live_YOUR_KEY"}}}}` - Workspace tools (12, require a Bearer API key): list_domains, get_domain, create_domain, update_domain, delete_domain, check_domain, get_connect_instructions, list_webhook_endpoints, create_webhook_endpoint, delete_webhook_endpoint, dns_check_records_exist, dns_check_records_match_exactly - Free diagnostic tools (16, no key — an unauthenticated tools/list returns these): tools_ssl_check, tools_dns_record_lookup, tools_whois_lookup, tools_cname_lookup, tools_http_header_checker, tools_dns_propagation_checker, tools_redirect_checker, tools_spf_record_checker, tools_dkim_record_checker, tools_dmarc_record_checker, tools_txt_record_lookup, tools_domain_age_checker, tools_domain_availability_checker, tools_subdomain_finder, tools_reverse_ip_lookup, tools_website_status_checker - Source & registry: GitHub https://github.com/CommonNinja/domainee-mcp-server · official MCP registry `dev.domainee/domainee` · Glama https://glama.ai/mcp/connectors/dev.domainee/domainee --- ## Introduction Source: https://domainee.dev/docs Connect your customers' domains to your origins, with automatic SSL, in two API calls. Welcome to Domainee. This product lets your customers point their own domains at your application — fully automated TLS, no DNS-validation back-and-forth, single CNAME for the customer. ### What you get - **Zero-touch SSL.** When a request first arrives for a customer's domain, we issue a cert and serve traffic. Renewal is automatic forever. - **A single REST API.** Create domains, route them to your origins, listen to webhooks. That's the whole API surface. - **Predictable pricing.** First 20 domains free, then $0.20/domain with volume discounts to 50% off. Bandwidth is 100 GB/month included + $0.05/GB. See [Pricing](https://domainee.dev/docs/pricing). ### How it works ``` Your customer's user → shop.acme.com │ CNAME to edge.domainee.dev │ ▼ Domainee TLS edge (issues cert, terminates TLS) │ ▼ Domainee L7 edge (looks up origin from your config) │ ▼ Your origin (e.g. https://acme.fly.dev) ``` The customer publishes a single CNAME (or, at an apex where their provider allows no CNAME, the two anycast A records from `dnsRecordOptions`). The first time someone hits it, we provision a Let's Encrypt cert (with on-demand TLS). After that, traffic flows through us to your origin URL. ### What you do 1. **Sign up** at [domainee.dev/sign-up](https://domainee.dev/sign-up). The free tier covers 20 domains and 100 GB bandwidth/month. Add a card before your first domain; it's charged $0 until you pass the free tier. 2. **Mint an API key** from the Developers page in your dashboard. 3. **Call `POST /v1/domains`** for each customer hostname you want to route. 4. **Tell your customer** to publish one CNAME, using [provider-specific steps](https://domainee.dev/docs/api/domains/getConnectInstructions) or the [connect widget](https://domainee.dev/docs/widget), with one-click setup when their DNS is on Cloudflare. We auto-detect when they do and flip the domain status to `verified`. Each domain is re-checked every 5 minutes, or immediately via `POST /v1/domains/{id}/check`. The [Quickstart](https://domainee.dev/docs/quickstart) walks through the whole flow with copy-pastable commands. ### Where to go next - **[Quickstart](https://domainee.dev/docs/quickstart)** — your first domain in 5 minutes - **[Authentication](https://domainee.dev/docs/authentication)** — API keys and Bearer tokens - **[Domains API](https://domainee.dev/docs/api/domains/createDomain)** — full reference - **[Webhooks](https://domainee.dev/docs/webhooks)** — get notified when domain status changes - **[MCP server](https://domainee.dev/docs/mcp)** — manage Domainee from Claude Desktop, Cursor, or any MCP client - **[Errors](https://domainee.dev/docs/errors)** — error codes, retries, idempotency ## Quickstart Source: https://domainee.dev/docs/quickstart Connect your first custom domain end-to-end in five minutes. By the end of this guide you'll have one of your customers' domains routing through Domainee to your origin, with automatic HTTPS. ### 1. Sign up and get an API key 1. [Create an account](https://domainee.dev/sign-up). 2. Open the [Developers](https://domainee.dev/developers) page in your dashboard. 3. Click **New key**, name it (e.g. `production`), copy the key — it's shown only once. Format: `sk_live_…`. ```bash export DOMAINEE_API_KEY=sk_live_... ``` ### 2. Register a domain Replace `shop.acme.com` with the customer hostname you want to route, and `https://acme.fly.dev` with your origin URL (the actual app the traffic should reach). ```bash curl -X POST https://api.domainee.dev/v1/domains \ -H "Authorization: Bearer $DOMAINEE_API_KEY" \ -H "content-type: application/json" \ -d '{ "hostname": "shop.acme.com", "originUrl": "https://acme.fly.dev" }' ``` You'll get back the domain object plus a list of DNS records the customer needs to publish: ```json { "domain": { "id": "8f09b47c-b42f-4d14-8395-2989db76e6f8", "hostname": "shop.acme.com", "originUrl": "https://acme.fly.dev", "status": "pending", "mode": "proxy", "dnsRecords": [ { "type": "CNAME", "name": "shop.acme.com", "value": "edge.domainee.dev", "purpose": "Traffic Routing" } ] }, "warnings": [] } ``` See [Create a domain](https://domainee.dev/docs/api/domains/createDomain) for every parameter. ### 3. Tell your customer what to add at their DNS provider Hand them the single CNAME from the response: ``` Type: CNAME Name: shop.acme.com Value: edge.domainee.dev ``` That's it — no validation record, no second CNAME for SSL. (If they're on Cloudflare, set the proxy status to **DNS only** so TLS passes through.) Rather than write the instructions yourself, call [`GET /v1/domains/{id}/instructions`](https://domainee.dev/docs/api/domains/getConnectInstructions) for steps written for the customer's actual DNS provider, or drop in the [connect widget](https://domainee.dev/docs/widget), which renders them for you. When the customer's DNS is on Cloudflare, both also offer a one-click button: the customer approves the records on Cloudflare's own screen and nobody types anything. #### Apex / root domains A CNAME can't live at the zone apex. Most providers paper over this with ALIAS, ANAME or CNAME flattening, and that does work. **Prefer the A records at a root domain anyway.** Every one of those mechanisms resolves our hostname once and republishes whatever single address it got back, so the apex ends up pinned to one of our regions with no failover. The anycast A records keep both regions, because the routing happens in the network rather than in DNS. For an apex hostname, `dnsRecordOptions` marks the `a` set as recommended and returns it first: ```json "dnsRecordOptions": [ { "method": "a", "recommended": true, "records": [ { "type": "A", "name": "acme.com", "value": "75.2.52.197", "purpose": "Traffic Routing" }, { "type": "A", "name": "acme.com", "value": "3.33.255.144", "purpose": "Traffic Routing" } ] }, { "method": "cname", "recommended": false, "records": [ { "type": "CNAME", "name": "acme.com", "value": "edge.domainee.dev", "purpose": "Traffic Routing" } ] } ] ``` **Tell the customer to add both A records.** One alone still works, but silently leaves them without redundancy. On a **subdomain** the recommendation flips the other way: `cname` comes back first, because a real CNAME there does give per-visitor regional routing and follows our infrastructure automatically. Take `dnsRecordOptions[0]` and you get the right one either way. `dnsRecords` is unchanged and still carries only the CNAME, so existing integrations that render it directly keep behaving exactly as before. ### 4. Wait for verification Our DNS monitor re-checks each domain every 5 minutes. Once it sees the record, it flips `status` from `pending` to `verified` on its own. A brand new domain is picked up on the first sweep after you create it, usually within a minute; after that it settles into the 5 minute cadence. You can also force an immediate check rather than waiting: ```bash DOMAIN_ID=8f09b47c-b42f-4d14-8395-2989db76e6f8 curl -X POST https://api.domainee.dev/v1/domains/$DOMAIN_ID/check \ -H "Authorization: Bearer $DOMAINEE_API_KEY" ``` ### 5. First HTTPS request ```bash curl -i https://shop.acme.com/some/path ``` The first request takes 10–30 seconds: our TLS edge sees a new SNI, asks our control plane "is this a verified domain?", then provisions a Let's Encrypt cert. After that, every request uses the cached cert (no LE round-trip). ### 6. Listen for status changes Subscribe to webhooks so you know when a domain is verified, when DNS breaks, when SSL expires, etc.: ```bash curl -X POST https://api.domainee.dev/v1/webhook-endpoints \ -H "Authorization: Bearer $DOMAINEE_API_KEY" \ -H "content-type: application/json" \ -d '{ "url": "https://your-app.com/webhooks/domainee", "events": [] }' ``` (Empty `events` array subscribes to all domain events.) See [Webhooks](https://domainee.dev/docs/webhooks) for signature verification and the full event list. ### What's next - [Domains API reference](https://domainee.dev/docs/api/domains/createDomain) — every field, every endpoint - [Webhook events](https://domainee.dev/docs/api/events) — what fires and when - [Errors](https://domainee.dev/docs/errors) — `monitor_status` values, retries, idempotency ## Request headers Source: https://domainee.dev/docs/request-headers What we add to a proxied request before it reaches your origin, and how to read the visitor's IP reliably. Every request we proxy to your origin carries a set of forwarding headers describing the original visitor and the hostname they asked for. The request body, method, path, query and your customer's own headers pass through untouched. ### What we send ``` x-forwarded-for: 203.0.113.42 x-real-ip: 203.0.113.42 x-domainee-client-ip: 203.0.113.42 x-forwarded-host: shop.acme.com x-forwarded-proto: https x-domainee-edge: 1 x-domainee-incoming-host: shop.acme.com ``` | Header | Meaning | | --- | --- | | `x-forwarded-for` | The visitor's IP address. | | `x-real-ip` | The same address, for frameworks that read this one. | | `x-domainee-client-ip` | The same address again. Read this one. See below. | | `x-forwarded-host` | The hostname the visitor requested, not your origin's hostname. | | `x-forwarded-proto` | Always `https` for proxied traffic. | | `x-domainee-edge` | Always `1`. Lets you tell Domainee traffic from direct traffic. | | `x-domainee-incoming-host` | Same value as `x-forwarded-host`, under a name nothing else rewrites. | ### Reading the visitor's IP Read `x-domainee-client-ip` first and fall back to `x-forwarded-for`: ```js const clientIp = req.headers["x-domainee-client-ip"] || req.headers["x-forwarded-for"]?.split(",")[0]?.trim(); ``` The reason for the third header is that `x-forwarded-for` and `x-real-ip` are well-known names, and the platform your origin runs on may rewrite them before your application code runs. Most managed hosts terminate connections at their own edge and set these headers from the address that connected to them, which for proxied traffic is us rather than your visitor. When that happens there is nothing we can put in those two headers that your app will actually see. `x-domainee-client-ip` is not a name any platform edge recognises, so it arrives intact. If your host leaves the standard headers alone, all three agree and it makes no difference which you read. **Do not trust these headers on requests that did not come from us.** Any client can send a header with any name. If your origin is reachable directly as well as through Domainee, gate this on `x-domainee-edge: 1` combined with a check that the request actually arrived over your Domainee hostname, or put your origin behind an allowlist. ### Host header By default we rewrite `Host` to your origin's hostname so origin servers that do virtual-host routing don't reject the request. The hostname your visitor asked for is always available in `x-forwarded-host`. If your application routes on `Host` itself and needs the original value, turn on [`keepHost`](https://domainee.dev/docs/api/domains/updateDomain) for the domain and we will pass the visitor's hostname through unchanged. `x-forwarded-host` is set either way. ### Apex and subdomain traffic Both DNS paths produce identical headers at your origin. A domain connected with A records at the apex and one connected with a `CNAME` to `edge.domainee.dev` enter our network differently, but the request your origin receives is the same shape in both cases. ## Authentication Source: https://domainee.dev/docs/authentication Bearer-token auth with API keys you mint from the dashboard. The Domainee REST API uses **Bearer-token authentication** with API keys you mint from the [Developers](https://domainee.dev/developers) page. ### API key format ``` sk_live_<48 random base64url chars> ``` Total length: 56 characters. The `sk_live_` prefix is constant; the rest is cryptographically random. ### Sending the key Every request to `https://api.domainee.dev/v1/*` must include: ``` Authorization: Bearer sk_live_... ``` Example: ```bash curl https://api.domainee.dev/v1/domains \ -H "Authorization: Bearer $DOMAINEE_API_KEY" ``` The header name and `Bearer` scheme are case-insensitive (`authorization: bearer` works too), per RFC 7235. ### Workspace scoping Every API key belongs to **exactly one workspace** — the workspace it was created in. All resources you create with that key (domains, webhook endpoints) belong to the same workspace and are billed against its subscription. If you have multiple workspaces, each one needs its own key. ### Managing keys In the dashboard at [/developers](https://domainee.dev/developers): - **New key** — generates a fresh key. Plaintext is shown ONCE in a modal — copy it before closing. - **Revoke** — invalidates the key immediately. Any in-flight requests with the revoked key get 401. We never store the plaintext server-side; only a SHA-256 hash and the first 16 characters (the `sk_live_xxxx…` prefix) for display in the dashboard. ### Storing keys safely - **Treat keys as production credentials.** Keep them in your secret manager (Doppler, AWS Secrets Manager, Railway env vars, etc.). - **Don't commit them** to git or include them in client-side code. They're server-side only — anyone with a key can manage all domains in your workspace. - **Rotate on compromise.** Revoke + mint a new key whenever you suspect leakage. - **Use one key per environment.** Production app uses one key, staging uses another. If staging leaks, only staging is exposed. ### Test credentials Domainee currently runs in live mode only. Use a workspace dedicated to testing if you need a sandbox. ### Errors | Status | When it fires | |---|---| | `401 unauthorized` | Missing `Authorization` header | | `401 unauthorized` | API key not found, malformed, or revoked | | `429 rate_limited` | Exceeded 60 req/min per key (configurable on request) | A revoked key returns `401` with `{"error":"unauthorized","message":"Invalid API key"}`. ## Webhooks Source: https://domainee.dev/docs/webhooks HMAC-signed webhook deliveries with automatic retries. Webhooks let you react to domain status changes without polling. We POST a JSON payload to your endpoint when a domain is created, verified, fails, or its monitor state changes. For the endpoint-management API see [Webhook endpoints](https://domainee.dev/docs/api/webhooks/createEndpoint). For each event's payload shape see [Webhook events](https://domainee.dev/docs/api/events). ### Delivery format Every webhook delivery includes: ``` x-domainee-signature: sha256= x-domainee-event: domain.verified x-domainee-delivery-id: content-type: application/json ``` The body is the JSON envelope: ```json { "id": "", "type": "domain.verified", "createdAt": "2026-05-05T11:39:19.406Z", "data": { ... } } ``` ### Verifying signatures The signature is `HMAC-SHA256(secret, raw_request_body)`. You **must** verify it before trusting the payload — otherwise anyone can forge events to your endpoint. #### Node.js example ```js function verifyDomaineeWebhook(rawBody, headerSig, secret) { const expected = "sha256=" + crypto .createHmac("sha256", secret) .update(rawBody) .digest("hex"); return crypto.timingSafeEqual( Buffer.from(headerSig), Buffer.from(expected), ); } app.post("/webhooks/domainee", express.raw({ type: "application/json" }), (req, res) => { const sig = req.header("x-domainee-signature"); if (!sig || !verifyDomaineeWebhook(req.body, sig, process.env.DOMAINEE_WEBHOOK_SECRET)) { return res.status(401).end(); } const event = JSON.parse(req.body.toString()); res.status(200).end(); }); ``` `req.body` must be the **raw bytes**, not a parsed JSON object. Frameworks that auto-parse JSON will produce different bytes when re-serialized, breaking verification. #### Why a custom header (vs. the more common `Stripe-Signature`)? Custom-named so host-routing proxies in front of your webhook receiver (Railway, Render, ALB host-based rules, etc.) don't rewrite or strip it. ### Retry behavior A delivery is considered successful when your endpoint returns a 2xx. Anything else (timeout, 4xx, 5xx) triggers retries on this schedule from the moment of the original event: | Attempt | Time after event | |---|---| | 1 | immediate | | 2 | +1 minute | | 3 | +5 minutes | | 4 | +30 minutes | | 5 | +2 hours | | 6 | +12 hours | After attempt 6, delivery is given up and marked as failed. ### Idempotency The `x-domainee-delivery-id` header is unique per delivery attempt. The payload's top-level `id` field is the **event id** — the same across retries of the same event. Dedupe on the event id. ### Filtering on your side The `x-domainee-event` header tells you the type before you parse the body — useful for routing to different handlers. ```js const eventType = req.header("x-domainee-event"); switch (eventType) { case "domain.verified": return handleVerified(req.body); case "domain.monitor_updated": return handleMonitor(req.body); default: return res.status(200).end(); } ``` ## Embeddable widget Source: https://domainee.dev/docs/widget Drop-in connect UI, so you don't build the DNS setup screen yourself. The widget is the whole last mile of a custom-domain setup as one script tag: provider detection, the record written the way that provider's form wants it, step-by-step instructions, the provider's own gotchas, one-click setup where it's available, and automatic polling until the domain goes live. ```html
``` ### Getting a token **Never put an `sk_live_` key in a browser.** It is workspace-wide and anyone can read it in devtools. The widget refuses to start if you pass one. Instead, mint a connect session on your server. It is scoped to one domain, expires quickly, and can only reach that domain's setup endpoints: ```bash curl -X POST https://api.domainee.dev/v1/domains/$DOMAIN_ID/connect-session \ -H "Authorization: Bearer $DOMAINEE_API_KEY" \ -H "content-type: application/json" \ -d '{ "ttlSeconds": 3600 }' ``` ```json { "connectSession": { "token": "dmn_cs_eyJ3Ijoi…", "expiresAt": "2026-09-09T18:36:15.000Z", "domainId": "8f09b47c-…", "hostname": "acme.com" } } ``` Hand `token` to the browser. The widget reads the domain id out of it, so you don't pass one separately. #### What the token can do Only these, and only for its own domain: | | | |---|---| | `GET /v1/domains/{id}` | read status | | `GET /v1/domains/{id}/instructions` | the setup steps | | `POST /v1/domains/{id}/check` | re-probe DNS | | `POST /v1/domains/{id}/dns/apply` | write records with a customer token | Everything else returns `403`, including listing domains, deleting one, touching a different domain, and minting another session. The allowlist is enforced centrally, so a route added later is not reachable by default. Tokens are signed rather than stored, so they cannot be revoked before they expire. Keep `ttlSeconds` short. The default is one hour; the maximum is 24. ### Options | Option | Required | Notes | |---|---|---| | `token` | yes | The connect session token. | | `apiBase` | | Defaults to `https://api.domainee.dev`. | | `hostname` | | Shown while the first request is in flight. | | `onVerified` | | Called once when the domain goes live. | `mount()` returns `{ destroy(), refresh() }`. Call `destroy()` when unmounting in a single-page app, or the poll keeps running. ### Behaviour - Polls every 15 seconds and stops as soon as the domain verifies. - Where the DNS provider has enabled our [Domain Connect](https://domainee.dev/docs/api/domains/getConnectInstructions) templates (Cloudflare, for example), the widget shows a one-click button above the records. The records and steps stay visible, so a customer who would rather add them by hand still can. - The button opens the provider's approval screen in a popup, or a new tab if the browser blocks popups. When the customer comes back to your page, the widget re-checks the domain straight away and twice more over the next 30 seconds, so `onVerified` fires without waiting for the next poll. - If provider detection fails, it still shows correct generic records rather than an error. - An expired token renders "this setup link has expired" rather than a blank panel, so mint a fresh one on page load. ### Styling Styles are inlined, so the widget can't restyle your page and your CSS can't break it. There is deliberately no theming API yet. If you need the setup UI to match your product exactly, build it against [`GET /v1/domains/{id}/instructions`](https://domainee.dev/docs/api/domains/getConnectInstructions), which returns everything the widget renders. ## MCP server (use Domainee from AI tools) Source: https://domainee.dev/docs/mcp Connect Claude Desktop, Cursor, or any MCP client to manage your domains in natural language. Domainee ships a [Model Context Protocol](https://modelcontextprotocol.io) server at `https://mcp.domainee.dev/mcp`. Once you wire it into your AI client, prompts like _"list my failing domains and tell me which DNS records are wrong"_ or _"redirect shop.acme.com to https://acme-store.fly.dev"_ just work — the model calls the same REST API as your code, scoped to the same workspace as your API key. ### Setup You need: 1. An API key from the [Developers](https://domainee.dev/developers) page (`sk_live_…`). 2. An MCP-aware client: Claude Desktop, Cursor, Cline, etc. #### Claude Desktop / Cursor Add to your `claude_desktop_config.json` (Claude Desktop) or `mcp.json` (Cursor): ```json { "mcpServers": { "domainee": { "url": "https://mcp.domainee.dev/mcp", "headers": { "Authorization": "Bearer sk_live_..." } } } } ``` Restart the app. Domainee's tools should appear in the MCP tools list, ready to call. #### From a script (raw JSON-RPC) Each tool call is a single HTTP POST. Useful for automation: ```bash curl https://mcp.domainee.dev/mcp \ -H "Authorization: Bearer $DOMAINEE_API_KEY" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "list_domains", "arguments": { "status": "verified" } } }' ``` ### What the model can do Eleven tools across three resource groups, all backed by the same REST API documented under [API Reference](https://domainee.dev/docs/api). #### Domains | Tool | What it does | |---|---| | `list_domains` | Paginated list with optional status filter | | `get_domain` | Fetch a single domain by id | | `create_domain` | Register a new customer hostname | | `update_domain` | Change `originUrl` / `mode` / redirect flags | | `delete_domain` | Stop routing a hostname | | `check_domain` | Force an immediate DNS / SSL probe | | `get_connect_instructions` | Detect the customer's DNS provider and return steps written for it, plus a one-click Domain Connect link where available (Cloudflare today) | #### Webhook endpoints | Tool | What it does | |---|---| | `list_webhook_endpoints` | List endpoints (secrets stripped) | | `create_webhook_endpoint` | Register a new HTTPS endpoint, returns the signing secret once | | `delete_webhook_endpoint` | Stop sending events to an endpoint | #### DNS checks | Tool | What it does | |---|---| | `dns_check_records_exist` | "At least one DNS value matches" — for verifying customer CNAMEs | | `dns_check_records_match_exactly` | Stricter: every returned value must match (no extras) | ### Things to try - "List my domains and tell me which ones have monitor status `dns_incorrect`." - "Create a domain `shop.acme.com` proxying to `https://acme-store.fly.dev` and turn on the www variant." - "For each pending domain, check its DNS and tell me whether the customer published the right CNAME." - "Delete every domain whose hostname starts with `test-`." - "Set up a webhook to `https://my-app.com/webhooks/domainee` for verification + failure events." The model will pick the right tool, fill in the arguments, and surface the API response back to you — including any errors (e.g. 402 billing_required if the workspace doesn't have a payment method on file, 409 conflict if the hostname is already taken). ### Auth model Every MCP request includes your `Authorization: Bearer sk_live_…` header. The MCP server forwards that token verbatim to `api.domainee.dev/v1/*`. Auth, rate limits, and the billing gate run on the API server — the MCP layer keeps no state. That means: - Your token = your access. Don't paste it into shared chat threads. - Rate limits (60 req/min per key by default — see [Errors → Rate limits](https://domainee.dev/docs/errors#rate-limits)) apply to MCP traffic the same way they apply to direct API calls. - 402 / 429 / 401 errors surface in the AI client as tool failures, so the model can decide whether to retry, ask you to add a card, etc. ### Limitations - **HTTP transport only.** No stdio mode — the auth model is "pass a Bearer header per request", not "spawn a process per user". - **Tool results are stringified JSON.** Long lists of domains expand the model's context; if you hit context limits, ask it to filter server-side (`status: "failed"`, `limit: 20`, etc.) instead of fetching everything and slicing client-side. - **Read + write parity with the REST API.** Anything the API doesn't expose (analytics, per-domain bandwidth) isn't available via MCP yet either. File a feature request if you need it. ### Source The MCP server source is part of the Domainee monorepo at [`mcp-server/`](https://github.com/CommonNinja/domainee-app/tree/main/mcp-server) — each tool is a small file under `src/tools/`. PRs welcome if you want to add a tool we don't expose yet. ## Errors and edge cases Source: https://domainee.dev/docs/errors HTTP status codes, monitor states, idempotency, and rate limits. Every API error returns a JSON body of the shape: ```json { "error": "", "message": "", "details": { ... } } ``` `details` is sometimes present — for example, a list of preflight warnings when domain creation fails preflight. ### HTTP status reference | Status | When | |---|---| | `200` | Success | | `201` | Resource created | | `400` | `bad_request` — your input is invalid (bad hostname, bad URL) | | `400` | `preflight_failed` — domain create blocked by CAA / SSRF | | `401` | `unauthorized` — missing or invalid Bearer token | | `402` | `billing_required`: no active subscription (card on file) on the workspace, required even for the first domain | | `403` | `forbidden` — token valid, but action not allowed (workspace mismatch) | | `404` | `not_found` — the resource id doesn't exist (or doesn't belong to you) | | `409` | `conflict` — hostname already in use | | `429` | `rate_limited` — too many requests; check `Retry-After` header | | `500` | `internal_error` — our problem; report it | ### Monitor states The `monitorStatus` field on a Domain reflects runtime health, computed from DNS + cert + origin reachability signals. Each domain is re-probed every 5 minutes. The accompanying `monitorMessage` gives a customer-facing explanation. | `monitorStatus` | What it means | What to show your customer | |---|---|---| | `unknown` | We haven't probed yet | "Setup in progress" | | `dns_not_resolving` | The hostname doesn't resolve at all | "Add the CNAME record at your registrar" | | `dns_incorrect` | DNS resolves but not to our edge | "Update your CNAME — it points elsewhere" | | `pending_ssl` | DNS is correct, waiting for cert | "Almost there — SSL is being issued" | | `active_ssl` | Verified and serving traffic | "Your site is live" (✅) | | `target_not_loading` | (Reserved for future origin health checks) | — | | `ssl_failed` | Cert issuance failed irrecoverably | "We couldn't issue an SSL cert. Check CAA records." | | `ssl_expired` | The cert window elapsed without renewal | "Your domain's certificate has expired. Re-verify DNS." | ### Idempotency For idempotent retries on `POST` requests, send an `Idempotency-Key` header. Same key + same path + same workspace within 24 hours returns the cached original response, with header `idempotent-replay: true`. ```bash curl -X POST https://api.domainee.dev/v1/domains \ -H "Authorization: Bearer $DOMAINEE_API_KEY" \ -H "Idempotency-Key: order-123-domain-create" \ -H "content-type: application/json" \ -d '{ "hostname": "shop.acme.com", "originUrl": "https://acme.fly.dev" }' ``` The key is scoped to your workspace — different workspaces with the same key don't collide. ### Rate limits Default: **60 requests per minute per API key** across all `/v1/*` endpoints. Some endpoints have additional, tighter limits: | Endpoint | Per-key limit | |---|---| | `POST /v1/domains` | 100/hour | | `POST /v1/domains/bulk` | 5/hour (×100 domains = 500 hostnames/hour ceiling) | | `POST /v1/domains/:id/check` | 60/min | | `/v1/dns/check-records-*` | 600/hour | When you hit a limit you get: ``` HTTP/1.1 429 Too Many Requests Retry-After: 42 x-ratelimit-limit: 60 x-ratelimit-remaining: 0 x-ratelimit-reset: 1714572420 { "error": "rate_limited", "message": "Too many requests for action 'create_domain'. Try again in 42s.", "details": { "action": "create_domain", "retryAfter": 42 } } ``` Respect `Retry-After` (in seconds) to avoid hammering us during cool-down. ### SSRF protection When you submit an `originUrl`, we resolve its hostname and reject any value that points at: - Private RFC 1918 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) - Loopback (127.0.0.0/8, ::1) - Link-local — including AWS/GCP metadata (169.254.169.254) - CGNAT (100.64.0.0/10) - Multicast and reserved ranges - Hostnames like `localhost`, `*.local`, `*.internal` This protects us (and you) from being used as a tunnel to internal infrastructure. ### CAA records If a customer's domain has CAA records that don't authorize Let's Encrypt or ZeroSSL, cert issuance will fail when first traffic arrives. We surface this as a **preflight warning** when you create the domain: ```json { "warnings": [ { "code": "caa_blocks_lets_encrypt", "message": "This domain has CAA records that don't authorize Let's Encrypt or ZeroSSL...", "caaRecords": ["digicert.com"] } ] } ``` Treat this as fatal even though the domain is created — traffic will never work until the customer fixes their CAA. The customer needs to add a CAA record permitting `letsencrypt.org` or remove their existing CAA records. ### When in doubt - Check the `x-domainee-request-id` response header on any failure — paste it in support requests so we can find the exact log line. - The dashboard's [Developers](https://domainee.dev/developers) page shows recent API calls per key. ## Pricing Source: https://domainee.dev/docs/pricing How billing works, what counts toward usage, and how the free tier is enforced. For the marketing pricing breakdown with an interactive estimator, see [/pricing](https://domainee.dev/pricing). This page covers the billing mechanics for developers integrating against the API. ### What you pay for Two usage axes, both metered automatically: 1. **Custom domains.** Count of `Domain` rows in your workspace. The first 20 are free; beyond that, $0.20/domain/month with volume discounts that reach 50% off at 10,001+ domains. 2. **Bandwidth.** Total bytes (request + response bodies) flowing through the edge for your domains. The first 100 GB/month is included on every workspace; $0.05/GB above. Workspaces without an active subscription are blocked after 100 GB/month instead of billed. Both axes appear as separate line items on the same monthly invoice. ### How quantity gets to Stripe - **Domains**: every `POST /v1/domains` and `DELETE /v1/domains/{id}` updates the subscription item's quantity in real time. Stripe prorates mid-cycle changes automatically. A reconciliation worker also re-pushes the count every 30 minutes as a safety net against drift. - **Bandwidth**: the edge counts bytes per request, buffers them in memory, and flushes to our control plane every 30 seconds. We aggregate to daily totals and push usage records to Stripe hourly with backdated timestamps so cycle boundaries are handled correctly. ### When am I charged? End of each monthly billing cycle. Stripe runs the graduated tier formula on your final domain count and sums all bandwidth usage records, generates an invoice, and charges the card on file automatically. If your usage stayed within the free quotas (≤20 domains and ≤100 GB), the invoice is **$0** and your card isn't touched. ### What happens at the limits | Scenario | Behavior | |---|---| | No card on file, creating any domain (even the first) | `POST /v1/domains` returns `402 billing_required`. Add a card to continue; it's charged $0 until you pass the free tier. | | Free tier, exceeded 100 GB this month | Customer traffic returns `402` at the edge until a card is added. New domain creates also blocked with `402`. | | Paid plan, 21st domain | Allowed. End-of-cycle invoice includes the chargeable domain count. | | Paid plan, 101 GB used | Allowed. End-of-cycle invoice includes 1 GB × $0.05 = $0.05 overage. | | Subscription `past_due` (failed payment) | New `POST /v1/domains` returns `402`. Existing traffic continues serving for ~3 weeks of dunning, then traffic is cut off. | | Subscription canceled | Edge returns `404` on every customer hostname for that workspace. | ### Admin bypass Workspaces owned by a platform admin (`users.isAdmin = true`) skip every billing gate — useful for internal/test workspaces. ### Volume discounts Apply automatically based on your **total** domain count (not chargeable count, since the 20 free domains count toward your tier). | Domain count | Per-domain | Discount | |---|---|---| | 21–1,000 | $0.20 | None | | 1,001–2,000 | $0.19 | 5% off | | 2,001–3,000 | $0.18 | 10% off | | 3,001–4,000 | $0.17 | 15% off | | 4,001–5,000 | $0.16 | 20% off | | 5,001–6,000 | $0.15 | 25% off | | 6,001–7,000 | $0.14 | 30% off | | 7,001–8,000 | $0.13 | 35% off | | 8,001–9,000 | $0.12 | 40% off | | 9,001–10,000 | $0.11 | 45% off | | 10,001+ | $0.10 | 50% off (cap) | Tiers are **graduated** — domains within each bracket are charged at that bracket's rate. So 1,500 domains = 20 free + 980 × $0.20 + 500 × $0.19 = **$291/mo**. ### Self-serve billing Customers manage their card, view invoices, and cancel from the Stripe-hosted **Customer Portal**, accessible from the dashboard's Billing page. You can drop them straight into the portal via: ```bash POST https://domainee.dev/api/billing/portal ``` (Authenticated via dashboard session — not API key. The Customer Portal isn't an API operation; it's a self-service UI for end users.) ## API Reference ### API overview Source: https://domainee.dev/docs/api Base URL, request format, and the resources exposed by the Domainee REST API. The Domainee REST API runs at: ``` https://api.domainee.dev/v1 ``` All requests are JSON over HTTPS, authenticated with a Bearer token. See [Authentication](https://domainee.dev/docs/authentication) for the key format and how to send it. #### Resources | Resource | What it represents | |---|---| | **[Domains](https://domainee.dev/docs/api/domains/createDomain)** | Customer hostnames routed through our edge to your origins. | | **[Webhook endpoints](https://domainee.dev/docs/api/webhooks/createEndpoint)** | URLs we POST to when domain events fire. | | **[Webhook events](https://domainee.dev/docs/api/events)** | Event types and payload shapes. | | **[DNS checks](https://domainee.dev/docs/api/dns/checkRecordsExist)** | On-demand DNS lookups for verifying customer records. | #### Conventions - All timestamps are ISO 8601 (UTC), e.g. `2026-05-05T11:39:19.406Z`. - All ids are UUIDv4 strings. - `application/json` is the only supported content type for request bodies. - Errors share the shape `{ "error": "", "message": "...", "details": {...} }` — see [Errors](https://domainee.dev/docs/errors) for the full reference. #### Quick example ```bash curl https://api.domainee.dev/v1/domains \ -H "Authorization: Bearer $DOMAINEE_API_KEY" ``` ```json { "domains": [ { "id": "8f09b47c-b42f-4d14-8395-2989db76e6f8", "hostname": "shop.acme.com", "originUrl": "https://acme.fly.dev", "status": "verified", "monitorStatus": "active_ssl" } ], "nextCursor": null } ``` ### Domains #### Create a domain Source: https://domainee.dev/docs/api/domains/createDomain POST /v1/domains — register a customer hostname. ``` POST /v1/domains ``` Registers a customer hostname for proxying or redirecting through our edge. Counts toward your domain quota immediately. ##### Request ```bash curl -X POST https://api.domainee.dev/v1/domains \ -H "Authorization: Bearer $DOMAINEE_API_KEY" \ -H "content-type: application/json" \ -d '{ "hostname": "shop.acme.com", "originUrl": "https://acme.fly.dev", "mode": "proxy", "keepHost": false, "redirectWww": true }' ``` ###### Body parameters | Field | Required | Default | Notes | |---|---|---|---| | `hostname` | ✅ | — | RFC 1123 hostname. Lowercased + dot-trimmed automatically. | | `originUrl` | ✅ | — | Must be `https://` or `http://`. We block private/loopback IPs (see [SSRF protection](https://domainee.dev/docs/errors#ssrf-protection)). | | `mode` | | `"proxy"` | `"proxy"` (reverse-proxy) or `"redirect"` (301/302). | | `keepHost` | | `false` | `proxy` mode only — forward original `Host` header. | | `redirectWww` | | `false` | Also serve and 301 the www variant. | | `redirectStatus` | | `301` | `301` (permanent) or `302` (temporary). | | `metadata` | | `{}` | Opaque object. We store and return it untouched. | ##### Response — `201 Created` ```json { "domain": { "id": "8f09b47c-b42f-4d14-8395-2989db76e6f8", "hostname": "shop.acme.com", "originUrl": "https://acme.fly.dev", "status": "pending", "mode": "proxy", "dnsRecords": [ { "type": "CNAME", "name": "shop.acme.com", "value": "edge.domainee.dev", "purpose": "Traffic Routing" } ] }, "warnings": [] } ``` `warnings` is always present, possibly empty. Non-fatal preflight findings (e.g. the hostname doesn't resolve yet, the origin is unreachable) appear here without blocking creation. ##### Errors | Status | Code | When | |---|---|---| | `400` | `bad_request` | Invalid hostname, invalid URL, reserved suffix | | `400` | `preflight_failed` | CAA blocks LE, origin SSRF | | `402` | `billing_required` | No card on file for the workspace (required for any domain, charged $0 under the free tier) | | `409` | `conflict` | Hostname already in use | ##### Idempotency Send `Idempotency-Key: ` to make retries safe — see [Idempotency](https://domainee.dev/docs/errors#idempotency). #### List domains Source: https://domainee.dev/docs/api/domains/listDomains GET /v1/domains — paginated list of every domain in your workspace. ``` GET /v1/domains ``` Returns every domain in the workspace tied to your API key. Cursor-paginated. ##### Request ```bash curl https://api.domainee.dev/v1/domains \ -H "Authorization: Bearer $DOMAINEE_API_KEY" ``` ###### Query parameters | Param | Notes | |---|---| | `status` | Filter: `pending` · `verified` · `failed` · `expired` | | `hostname` | Exact-match lookup (case-insensitive). Returns at most one domain; `nextCursor` is always `null`. Saves you paging the whole workspace when you only need one domain. | | `limit` | 1–200, default 50 | | `cursor` | Pagination cursor from a previous `nextCursor` | ###### Looking up by hostname ```bash curl "https://api.domainee.dev/v1/domains?hostname=shop.acme.com" \ -H "Authorization: Bearer $DOMAINEE_API_KEY" ``` Empty `domains` array if no match — never a 404. ##### Response — `200 OK` ```json { "domains": [ { "id": "8f09b47c-b42f-4d14-8395-2989db76e6f8", "hostname": "shop.acme.com", "originUrl": "https://acme.fly.dev", "status": "verified", "mode": "proxy", "monitorStatus": "active_ssl", "metadata": { "orderId": "ord_123", "tenant": "acme" }, "verifiedAt": "2026-05-05T11:39:19.406Z", "createdAt": "2026-05-05T11:39:12.884Z" } ], "nextCursor": "8f09b47c-b42f-4d14-8395-2989db76e6f8" } ``` Whatever you stored in `metadata` at create time round-trips on every list — useful for stitching domains back to your own records (workspace, tenant, customer id, etc.) without a separate lookup table. `nextCursor` is `null` once you've reached the last page. Pass it back as `?cursor=...` to get the following page. ##### The Domain object Full field reference for the objects returned in `domains[]`: | Field | Type | Notes | |---|---|---| | `id` | string | UUID. Use it on `GET`/`PATCH`/`DELETE`. | | `hostname` | string | The customer's hostname. Lowercase, RFC-1123. | | `originUrl` | string | URL we forward to (`proxy` mode) or redirect to (`redirect` mode). | | `status` | enum | `pending` · `verified` · `failed` · `expired`. Driven by DNS state. | | `mode` | enum | `proxy` (default) — reverse-proxy traffic. `redirect` — return 301/302. | | `keepHost` | boolean | `proxy` mode only: forward customer's `Host` header instead of rewriting. | | `redirectWww` | boolean | If true, also issue cert for the www variant and 301 it to canonical. | | `redirectStatus` | 301 \| 302 | HTTP code for `redirect` mode. | | `dnsRecords` | array | The records the customer must publish. | | `isResolving` | boolean | Whether DNS resolves the hostname at all. | | `pointsToEdge` | boolean | Whether DNS points to our edge IPs. | | `dnsPointedAt` | string[] | Snapshot of resolved IPs from last probe. | | `monitorStatus` | enum | Computed health. See [Monitor states](https://domainee.dev/docs/errors#monitor-states). | | `monitorMessage` | string | Human-readable explanation of `monitorStatus`. | | `metadata` | object | Whatever you sent on [Create](https://domainee.dev/docs/api/domains/createDomain), echoed back verbatim. Useful for keying domains to records on your side. | | `verifiedAt` | string \| null | When DNS first pointed at our edge. | | `createdAt` | string | ISO 8601 timestamp. | | `updatedAt` | string | ISO 8601 timestamp. | #### Get a domain Source: https://domainee.dev/docs/api/domains/getDomain GET /v1/domains/{id} — fetch one domain by id. ``` GET /v1/domains/{id} ``` Returns one domain in full. Use [List domains](https://domainee.dev/docs/api/domains/listDomains) if you don't already know the id. ##### Request ```bash curl https://api.domainee.dev/v1/domains/$DOMAIN_ID \ -H "Authorization: Bearer $DOMAINEE_API_KEY" ``` ##### Response — `200 OK` ```json { "id": "8f09b47c-b42f-4d14-8395-2989db76e6f8", "workspaceId": "cb9e05b7-1bc3-470f-8955-3cd24930d25a", "hostname": "shop.acme.com", "originUrl": "https://acme.fly.dev", "status": "verified", "mode": "proxy", "keepHost": false, "redirectWww": false, "redirectStatus": 301, "dnsRecords": [ { "type": "CNAME", "name": "shop.acme.com", "value": "edge.domainee.dev", "purpose": "Traffic Routing" } ], "isResolving": true, "pointsToEdge": true, "dnsPointedAt": ["35.165.194.233", "52.39.55.154"], "monitorStatus": "active_ssl", "monitorMessage": "Domain is fully active and serving traffic.", "metadata": { "orderId": "ord_123", "tenant": "acme" }, "verifiedAt": "2026-05-05T11:39:19.406Z", "lastCheckedAt": "2026-05-05T13:10:04.000Z", "lastMonitoredAt": "2026-05-05T13:10:04.000Z", "createdAt": "2026-05-05T11:39:12.884Z", "updatedAt": "2026-05-05T11:39:19.406Z" } ``` `metadata` is whatever you sent on [Create](https://domainee.dev/docs/api/domains/createDomain) — stored verbatim and returned on every read. Use it to key Domainee records back to records on your side (tenant id, internal order id, etc.) without maintaining a separate lookup table. ##### Errors | Status | Code | When | |---|---|---| | `404` | `not_found` | The id doesn't exist or doesn't belong to your workspace | #### Update a domain Source: https://domainee.dev/docs/api/domains/updateDomain PATCH /v1/domains/{id} — change origin, mode, or routing flags. ``` PATCH /v1/domains/{id} ``` Updates any combination of: `originUrl`, `mode`, `keepHost`, `redirectWww`, `redirectStatus`, `metadata`. Hostname is **immutable** — delete and re-create if you need to change it. ##### Request ```bash curl -X PATCH https://api.domainee.dev/v1/domains/$DOMAIN_ID \ -H "Authorization: Bearer $DOMAINEE_API_KEY" \ -H "content-type: application/json" \ -d '{ "originUrl": "https://new-origin.fly.dev" }' ``` ###### Body parameters | Field | Notes | |---|---| | `originUrl` | New origin URL. Must be `https://` or `http://`. SSRF-checked. | | `mode` | `"proxy"` or `"redirect"`. | | `keepHost` | Forward original `Host` header (proxy mode). | | `redirectWww` | Also serve and 301 the www variant. | | `redirectStatus` | `301` or `302` for redirect mode. | | `metadata` | Replace the metadata object wholesale. | ##### Response — `200 OK` Returns the updated [Domain object](https://domainee.dev/docs/api/domains/getDomain). ##### Cache propagation The edge proxy caches resolver lookups for **60 seconds**. Changes to `originUrl` / `mode` / `keepHost` propagate within that window without any extra action. ##### Errors | Status | Code | When | |---|---|---| | `400` | `bad_request` | Invalid `originUrl` or unknown field | | `400` | `preflight_failed` | New `originUrl` resolved to a private/loopback IP | | `404` | `not_found` | The id doesn't exist or doesn't belong to your workspace | #### Delete a domain Source: https://domainee.dev/docs/api/domains/deleteDomain DELETE /v1/domains/{id} — stop routing a hostname. ``` DELETE /v1/domains/{id} ``` Stops traffic at our edge immediately. The customer's CNAME keeps pointing at us until they remove it on their side, but that's harmless — once a hostname has no domain row, our edge returns `404`. The domain count is decremented on the Stripe subscription in the same operation, so removing domains mid-cycle is prorated automatically. ##### Request ```bash curl -X DELETE https://api.domainee.dev/v1/domains/$DOMAIN_ID \ -H "Authorization: Bearer $DOMAINEE_API_KEY" ``` ##### Response — `204 No Content` Empty body. The [`domain.deleted`](https://domainee.dev/docs/api/events/domainDeleted) webhook fires for every subscribed endpoint. ##### Errors | Status | Code | When | |---|---|---| | `404` | `not_found` | The id doesn't exist or doesn't belong to your workspace | #### Get connect instructions Source: https://domainee.dev/docs/api/domains/getConnectInstructions GET /v1/domains/{id}/instructions — one-click Domain Connect where available, provider-specific manual steps everywhere else. ``` GET /v1/domains/{id}/instructions ``` Returns setup steps written for the DNS provider actually serving the customer's domain, instead of a generic "add a CNAME". Most custom-domain connections die at the last mile: the end user opens a registrar panel they have never seen and has to guess which of three name fields to use, whether their provider can even hold a CNAME-like record at a root domain, and which provider-specific toggle silently breaks everything. This endpoint answers all three. Domainee fingerprints the provider from the zone's public `NS` records, so there is nothing to configure and no credentials involved. > **Don't want to build this UI?** The [connect widget](https://domainee.dev/docs/widget) renders > these instructions, the one-click button and the verification polling for > you, from one `