Buy a domain
POST /v1/domain-purchases — charge your card and register a hostname for your end-user.
POST /v1/domain-purchases
Places a hold on your workspace's saved Stripe card, registers the hostname at the upstream registrar with the contact you supply, then charges the card. Returns the purchase record on success. If the registrar refuses, the hold is released and nothing is charged.
Request
curl -X POST https://api.domainee.dev/v1/domain-purchases \
-H "Authorization: Bearer $DOMAINEE_API_KEY" \
-H "content-type: application/json" \
-d '{
"hostname": "janesbakery.com",
"years": 1,
"registrant": {
"firstName": "Jane",
"lastName": "Smith",
"email": "jane@bakery.example",
"phone": "+1.5551234567",
"address1": "123 Main St",
"city": "Portland",
"stateOrProvince": "OR",
"postalCode": "97201",
"country": "US"
},
"customerReference": "user_jane_42"
}'
Body parameters
| Field | Required | Default | Notes |
|---|---|---|---|
hostname | ✅ | — | The domain to register. Lowercased + trimmed automatically. |
years | 1 | Integer 1–10. How long to register for. | |
registrant | ✅ | — | Contact info for the legal owner of record. See below. |
enableWhoisPrivacy | true | Free WHOIS privacy at the registrar. Set false only if you need public WHOIS. | |
autoRenew | false | Renew at the registrar automatically before expiry. | |
maxTotalCents | — | Safety ceiling — if live price exceeds this, we return 409 price_changed before charging. Recommended on every call to protect against premium-price surprises. | |
customerReference | — | Opaque string echoed back on reads + webhooks. Stash your internal user-id, order-id, etc. — then filter the list endpoint by it later. | |
autoConnect | — | When present, after registration we ALSO provision the hostname on Domainee's edge and set the registrar DNS to CNAMEs (apex + www) pointing at us. One API call ships a working live domain. Replaces the whole record set — add MX / TXT records afterwards. See below. |
autoConnect object
"autoConnect": {
"originUrl": "https://janesbakery.acmesites.app", // required
"keepHost": false // optional, default false
}
| Field | Required | Notes |
|---|---|---|
originUrl | ✅ | Where the edge proxies requests. Must be https://. |
keepHost | Forward the customer's original Host header. Default false. |
If autoConnect is set, the response's purchase.connectedDomainId field
is populated with the matching /v1/domains row id, and the domain
resolves to your app within a minute of DNS propagation.
If any step in the chain fails (edge provisioning, DNS update), the
entire transaction rolls back: the hold on your card is released, the
purchase row is marked failed, and domain_purchase.failed fires.
Registrant object
All fields are forwarded to the registrar verbatim. Your end-user is the legal owner of record, not you. ICANN requires the address fields to be real — fake data risks suspension of the domain.
| Field | Required | Notes |
|---|---|---|
firstName | ✅ | |
lastName | ✅ | |
email | ✅ | Registrar may send verification mail here. |
phone | ✅ | E.164 with dot: +1.5551234567. |
address1 | ✅ | |
address2 | ||
city | ✅ | |
stateOrProvince | ✅ | |
postalCode | ✅ | |
country | ✅ | ISO 3166-1 alpha-2 (e.g. "US"). |
organization | Set for business-owned domains. |
Response — 201 Created
{
"purchase": {
"id": "f8a0c1b9-1234-…",
"workspaceId": "ws_…",
"hostname": "janesbakery.com",
"years": 1,
"wholesaleCents": 1418,
"feeCents": 100,
"totalCents": 1518,
"currency": "USD",
"registrar": "namecheap",
"registrarDomainId": "182739",
"status": "completed",
"registrant": {
"firstName": "Jane",
"lastName": "Smith",
"email": "jane@bakery.example",
"country": "US"
},
"whoisPrivacyEnabled": true,
"autoRenew": false,
"expiresAt": "2027-05-17T15:42:11.000Z",
"customerReference": "user_jane_42",
"createdAt": "2026-05-17T15:42:09.000Z",
"updatedAt": "2026-05-17T15:42:11.000Z"
}
}
Errors
| Code | Status | When |
|---|---|---|
unavailable | 409 | Hostname is already registered or not available for this TLD. |
price_changed | 409 | Live price exceeded maxTotalCents. No charge made. |
domain_restricted | 409 | The registrar refuses this name, usually because it contains a restricted phrase. /check can report these names as available. No charge made. Retrying won't help, so pick another name. |
billing_required | 402 | The hold on your card failed: declined card, no card on file, 3DS required, insufficient funds. No registration attempted. |
registration_failed | 502 | The registrar refused. The hold on your card was released, nothing was charged, and the row is marked failed. The error message contains the registrar's reason. |
bad_request | 400 | Validation failure — missing/malformed body. |
Side effects
- A row is written to
domain_purchasesfor your workspace. - A hold for the full
totalCentsis placed on your workspace's Stripe card. - The hostname is registered at the upstream registrar with the supplied contact as the legal owner.
- The hold is captured, so your card is charged. If registration fails, the hold is released instead. Your bank may show it as pending for a few days.
- One of two webhook events fires:
domain_purchase.completed— happy pathdomain_purchase.failed— charge failed or registrar refused
Idempotency
Send an Idempotency-Key header to dedupe retries:
Idempotency-Key: 8a04...
We cache the response for 24 hours per workspaceId × method × path × key.
Retries with the same key return the original response without re-charging
or re-registering. 402 and 5xx responses aren't cached, so a failed charge
can be retried with the same key once the billing issue is fixed.
After the purchase
Without autoConnect, the domain is registered but not yet
configured to serve your app's content. Call
POST /:id/connect when
you're ready; it provisions the hostname on the edge and sets the DNS in
one call.
Either way, connecting replaces the domain's entire DNS record set with
our CNAMEs. If the domain needs email (MX, SPF, DKIM, DMARC) or other
records, add them after connecting via
GET /:id/dns +
PUT /:id/dns. Records set
before connecting are removed.