Domains
Search, price, register, renew, and manage domains using the workspace wallet.
Domains belong to the workspace, not to a project. Every call on this page takes a workspace token (sk_ws_…) with the domains scope. A project token (sk_proj_…) is refused.
Before the first purchase, save the workspace's registrant, the legal owner of the domains it buys, in the console under Settings → Domains. Every purchase made with a token is registered to it.
Throughout this page:
BASE = https://api.cloud.simplifyd.com/v1/workspaces/{workspace}/domains{workspace} | The workspace's id. See how resources are addressed. |
{domain} | A domain's id, the slug returned when you buy it or list domains. Not its name. |
| Money | Every amount is an integer in centiKobo: ₦1 = 10,000. 189200000 is ₦18,920. |
Endpoints
| Method | Path | What it does |
|---|---|---|
GET | {BASE}/search | Prices a word under the popular TLDs |
GET | {BASE}/quote | Prices one name for a number of years |
POST | {BASE} | Buys a domain |
GET | {BASE} | Lists the workspace's domains |
GET | {BASE}/{domain} | One domain, with its registrant verification, nameservers and orders |
PATCH | {BASE}/{domain} | Turns auto-renew or the transfer lock on or off |
POST | {BASE}/{domain}/renew | Renews a domain now |
GET | {BASE}/registrant | The registrant the workspace buys as |
Search for names
GET {BASE}/search?q={query}Returns one-year prices for the requested domain and the same name under .com, .ng, .com.ng, .io, .co, .app, .dev, .net, .org, .xyz and .tech.
Query parameters
| Parameter | Description | |
|---|---|---|
q | Required | A word (acme) or a full name (acme.com). A pasted URL works too: https://www.acme.com/about searches acme.com. |
curl "$BASE/search?q=acme" \
-H "Authorization: Bearer $SIMPLIFYD_WORKSPACE_TOKEN"const response = await fetch(`${BASE}/search?q=${encodeURIComponent('acme')}`, {
headers: { Authorization: `Bearer ${process.env.SIMPLIFYD_WORKSPACE_TOKEN}` },
});
const { results } = await response.json();
const forSale = results.filter((r) => r.available);client := cloud.NewClient(cloud.WithToken(os.Getenv("SIMPLIFYD_WORKSPACE_TOKEN")))
domains := client.Workspace(workspace).Domains()
results, err := domains.Search(ctx, "acme")Response
200 OK. One quote per name:
{
"results": [
{ "name": "acme.com", "available": false, "reason": "already registered", "years": 1, "price": 0, "vat": 0, "total": 0, "renewal_price": 0 },
{ "name": "acme.io", "available": true, "years": 1, "price": 176000000, "vat": 13200000, "total": 189200000, "renewal_price": 211200000 },
{ "name": "acme.ng", "available": false, "reason": ".ng domains are not available yet", "years": 1, "price": 0, "vat": 0, "total": 0, "renewal_price": 0 }
]
}Quote a name
GET {BASE}/quote?name={name}&years={years}Returns a quote for a domain and registration period. Send the quoted total as confirm_total when purchasing.
Query parameters
| Parameter | Description | |
|---|---|---|
name | Required | The full name, e.g. acme.com. |
years | Optional | 1 to 10. Defaults to 1. |
curl "$BASE/quote?name=acme.io&years=2" \
-H "Authorization: Bearer $SIMPLIFYD_WORKSPACE_TOKEN"const params = new URLSearchParams({ name: 'acme.io', years: '2' });
const response = await fetch(`${BASE}/quote?${params}`, {
headers: { Authorization: `Bearer ${process.env.SIMPLIFYD_WORKSPACE_TOKEN}` },
});
const { quote } = await response.json();quote, err := domains.Quote(ctx, "acme.io", 2)Response
200 OK
{
"quote": {
"name": "acme.io",
"available": true,
"years": 2,
"price": 352000000,
"vat": 26400000,
"total": 378400000,
"renewal_price": 211200000
}
}The quote object
| Field | Description |
|---|---|
name | The name, lower-cased. |
available | Whether it can be bought now. |
reason | Why not, when available is false: taken, a TLD not sold yet, not a valid name. |
years | The term priced. |
price | The charge for years, before VAT. |
vat | VAT on price. |
total | price + vat: what leaves the wallet. Send this as confirm_total. |
renewal_price | What one year's renewal costs later, before VAT. |
Buy a domain
POST {BASE}
Content-Type: application/jsonCharges the wallet and registers the name to the workspace's saved registrant, on Simplifyd Cloud's nameservers, with the transfer lock on and WHOIS privacy. .ng and .com.ng names are registered without WHOIS privacy, so the registrant's details are public. Its DNS zone goes live with it: manage the records with the DNS API.
Body
| Field | Description | |
|---|---|---|
name | Required | The full name to buy. |
confirm_total | Required with a token | The total you agreed to, from a quote. Nothing is charged unless it is exactly the price at that moment. See confirming the price. |
years | Optional | 1 to 10. Defaults to 1. |
auto_renew | Optional | Renew from the wallet 30 days before expiry. Defaults to true. |
A token cannot send registrant: it always buys as the workspace's saved registrant.
TOTAL=$(curl -s "$BASE/quote?name=acme.io&years=1" \
-H "Authorization: Bearer $SIMPLIFYD_WORKSPACE_TOKEN" | jq '.quote.total')
curl -X POST "$BASE" \
-H "Authorization: Bearer $SIMPLIFYD_WORKSPACE_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"name\":\"acme.io\",\"years\":1,\"confirm_total\":$TOTAL}"const headers = {
Authorization: `Bearer ${process.env.SIMPLIFYD_WORKSPACE_TOKEN}`,
'Content-Type': 'application/json',
};
const { quote } = await (await fetch(`${BASE}/quote?name=acme.io&years=1`, { headers })).json();
if (!quote.available) throw new Error(`acme.io cannot be bought: ${quote.reason}`);
// Your own limit, checked before anything is spent.
if (quote.total > 250_000_000) throw new Error(`acme.io costs more than ₦25,000`);
const response = await fetch(BASE, {
method: 'POST',
headers,
body: JSON.stringify({ name: 'acme.io', years: 1, confirm_total: quote.total }),
});
const body = await response.json();
if (response.status === 409) {
// The price moved since the quote. Nothing was charged; body.total is the new price.
throw new Error(body.message);
}
if (!response.ok) throw new Error(body.message);
const domain = body.domain; // domain.status is "active" (201) or "registering" (202)quote, err := domains.Quote(ctx, "acme.io", 1)
if err != nil {
return err
}
if !quote.Available {
return fmt.Errorf("acme.io cannot be bought: %s", quote.Reason)
}
d, err := domains.Register(ctx, cloud.RegisterDomainInput{
Name: "acme.io",
Years: 1,
ConfirmTotal: "e.Total,
})
if pce, ok := cloud.AsPriceConfirmation(err); ok {
return fmt.Errorf("the price moved to %s; nothing was charged", cloud.FormatNaira(pce.Total))
}Response
201 Created when registration is confirmed immediately:
{
"domain": {
"slug": "0192f3d4-1b2a-7c88-a1e0-5d6c7b8a9f01",
"name": "acme.io",
"status": "active",
"expires_at": "2027-10-14T09:30:00Z",
"auto_renew": true,
"registrant": {
"first_name": "Ada", "last_name": "Obi", "company_name": "Acme Ltd",
"address1": "1 Marina", "address2": "", "city": "Lagos", "state": "LA", "zip": "101001",
"country": "NG", "email": "ada@acme.ng", "phone": "+2348012345678"
},
"registrant_verification": { "status": "pending" },
"created_at": "2026-10-14T09:30:00Z"
}
}202 Accepted with "status": "registering" when confirmation is pending. The wallet has been charged. Poll Get a domain approximately once per minute until the status is active or failed. Failed registrations are refunded. .ng and .com.ng registrations can stay registering for longer while the registry reviews them.
active is not always final. In rare cases the registry rejects a registration after the registrar has confirmed it. The domain then becomes failed, and everything charged for it is refunded to the wallet. See failed domains.
The registrar emails the registrant to confirm their address. It must be confirmed within 15 days, or the domain stops resolving until it is. Follow this in registrant_verification. .ng and .com.ng domains have no such email, and their registrant_verification stays unknown.
Confirming the price
confirm_total is checked against the price at the moment the wallet is debited. If it is missing (with a token) or different, nothing is charged and the answer is 409 Conflict with the current total:
{
"success": false,
"code": "ERR_PRICE_CHANGED",
"message": "the price is ₦18,920, not the ₦18,000 confirmed; nothing was charged. Send confirm_total=189200000 to pay it",
"total": 189200000
}code | When |
|---|---|
ERR_PRICE_CONFIRMATION_REQUIRED | A token left out confirm_total. |
ERR_PRICE_CHANGED | confirm_total was not the price. |
Check total against what you are willing to pay, then send the same request with it. That also means a script can skip the quote: buy without confirm_total, read the price from the 409, and confirm it.
Errors
| Status | When |
|---|---|
400 | The name cannot be bought (taken, or a TLD not sold yet); years is outside 1–10; no registrant is saved; the wallet is short (the message says by how much); the workspace is suspended for an unpaid balance. |
403 | A token sent registrant, or the token's creator can no longer buy domains. |
409 | The price was not confirmed. See above. |
List domains
GET {BASE}curl "$BASE" -H "Authorization: Bearer $SIMPLIFYD_WORKSPACE_TOKEN"const response = await fetch(BASE, {
headers: { Authorization: `Bearer ${process.env.SIMPLIFYD_WORKSPACE_TOKEN}` },
});
const { domains, sales_enabled } = await response.json();list, err := domains.List(ctx)
// list.Domains, list.SalesEnabledResponse
200 OK. Every domain in the workspace, sorted by name, in one response. The list is not paginated, so a name that is not in it is not one of this workspace's domains. Failed domains are left out.
A purchase is recorded together with its charge, before the registrar is asked. If a buy request times out, list the domains:
- The name is listed: the wallet was charged, and the domain is
registeringoractive. Follow it with get. - The name is not listed: either nothing was charged, or the registration failed and was refunded.
Buying the same name again while the first request is still running answers 400 saying it is already registered, so one name cannot be charged twice.
{
"domains": [
{
"slug": "0192f3d4-1b2a-7c88-a1e0-5d6c7b8a9f01",
"name": "acme.io",
"status": "active",
"expires_at": "2027-10-14T09:30:00Z",
"auto_renew": true,
"registrant": { "first_name": "Ada", "last_name": "Obi", "email": "ada@acme.ng", "country": "NG", "...": "..." },
"registrant_verification": { "status": "verified" },
"created_at": "2026-10-14T09:30:00Z"
}
],
"sales_enabled": true
}| Field | Description |
|---|---|
slug | The domain's id, for the calls below. |
status | registering, active, or expired. A failed domain is not listed; read it by its id with get. |
expires_at | When the registration ends. Absent while registering. |
auto_renew | Whether it renews itself from the wallet. |
registrant_verification | Whether the registrant has confirmed their address. See registrant verification. |
renewal_error | Why the last automatic renewal failed, e.g. the wallet was short. Absent otherwise. |
sales_enabled | Whether new domains can be bought right now. |
Get a domain
GET {BASE}/{domain}Path parameters
| Parameter | Description |
|---|---|
domain | The domain's id (slug). |
curl "$BASE/0192f3d4-1b2a-7c88-a1e0-5d6c7b8a9f01" \
-H "Authorization: Bearer $SIMPLIFYD_WORKSPACE_TOKEN"const response = await fetch(`${BASE}/${domainId}`, {
headers: { Authorization: `Bearer ${process.env.SIMPLIFYD_WORKSPACE_TOKEN}` },
});
const { domain } = await response.json();
if (domain.registrant_verification.status === 'locked') {
console.warn(`${domain.name} has stopped resolving: ${domain.registrant.email} has not confirmed their address`);
}detail, err := domains.Get(ctx, domainID)Response
200 OK. The fields from the list, and:
{
"domain": {
"slug": "0192f3d4-1b2a-7c88-a1e0-5d6c7b8a9f01",
"name": "acme.io",
"status": "active",
"expires_at": "2027-10-14T09:30:00Z",
"auto_renew": true,
"registrant": { "...": "..." },
"registrant_verification": { "status": "pending", "verify_by": "2026-10-29T09:30:00Z" },
"created_at": "2026-10-14T09:30:00Z",
"locked": true,
"nameservers": ["taiwo.ns.simplifyd.net", "kehinde.ns.simplifyd.net"],
"transfer_locked_until": "2026-12-13T09:30:00Z",
"orders": [
{
"slug": "0192f3d4-1b2b-7a10-b3c4-1e2f3a4b5c6d",
"kind": "registration",
"years": 1,
"amount": 176000000,
"vat": 13200000,
"status": "completed",
"created_at": "2026-10-14T09:30:00Z"
}
]
}
}| Field | Description |
|---|---|
registrant_verification | Whether the registrant has confirmed their address. See below. |
locked | The registrar transfer lock. While it is on, the domain cannot be moved to another registrar. It says nothing about verification. |
nameservers | The nameservers the registrar has for it. |
transfer_locked_until | When the registry's own lock after a registration or transfer ends. No setting lifts it. Absent once it has passed. |
orders | Every charge for the domain, newest first: kind is registration or renewal, status is pending, completed or refunded, and failure says why one was refunded. |
locked, nameservers and transfer_locked_until come from the registrar when you ask. For a domain that is not active or expired, or if the registrar cannot be reached, locked is false and the other two are absent.
An id that is not one of this workspace's domains answers 400 with "message": "domain not found".
Registrant verification
| Field | Description |
|---|---|
registrant_verification.status | One of the statuses below. |
registrant_verification.verify_by | The registrar's deadline for confirming the address, once it has given one. Absent otherwise, and once verified. |
status | What it means |
|---|---|
unknown | The registrar has not reported on this domain yet. Its report can take a few minutes after a purchase. Domains are also checked with the registrar once a day. |
pending | Waiting for the registrant to confirm the email the registrar sent them. The domain still resolves. |
verified | Confirmed. |
locked | The address was not confirmed in time, and the domain has stopped resolving until it is and the lock lifts. |
The same address can cover several domains, so confirming it can move them all at once. A domain stays locked until the lock itself is lifted, which can be shortly after the address is confirmed.
Failed domains
A domain is failed when its registration was refused, was never completed, or was rejected by the registry after being confirmed. In every case:
- Each order for it has
status: "refunded", andfailuresays why. A rejection refunds renewals bought in the meantime as well, VAT included. - The name is free to buy again, under a new id.
- If it had become
active, its DNS zone is removed along with its records, unless a service still uses the name. Either way, the name does not resolve. - It can be read with this call, but it is no longer listed, and changing or renewing it answers
400with"message": "domain not found".
Change auto-renew or the transfer lock
PATCH {BASE}/{domain}
Content-Type: application/jsonBody
Send either field, or both. A field left out is not changed.
| Field | Description |
|---|---|
auto_renew | true to renew from the wallet 30 days before expiry, false to let it lapse. |
locked | true to turn the registrar transfer lock on, false to turn it off. |
curl -X PATCH "$BASE/0192f3d4-1b2a-7c88-a1e0-5d6c7b8a9f01" \
-H "Authorization: Bearer $SIMPLIFYD_WORKSPACE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"auto_renew": false}'const response = await fetch(`${BASE}/${domainId}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.SIMPLIFYD_WORKSPACE_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ auto_renew: false }),
});
const { domain } = await response.json();off := false
detail, err := domains.Update(ctx, domainID, cloud.UpdateDomainInput{AutoRenew: &off})Response
200 OK with the domain as get returns it.
Renew a domain
POST {BASE}/{domain}/renew
Content-Type: application/jsonAdds years to the registration now, charged to the wallet. A domain with auto_renew on renews itself 30 days before it expires, so this is only needed to add years early, or to renew one with auto-renew off.
Body
| Field | Description | |
|---|---|---|
confirm_total | Required with a token | The total you agree to pay, VAT included. Works as it does for buying. |
years | Optional | 1 to 10. Defaults to 1. |
There is no quote for a renewal. To learn the price, send the request without confirm_total: a token is answered 409 with the price in total, and nothing is charged. Then send it again with that total.
# 1. Learn the price: answered 409, nothing charged.
TOTAL=$(curl -s -X POST "$BASE/$DOMAIN_ID/renew" \
-H "Authorization: Bearer $SIMPLIFYD_WORKSPACE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"years":1}' | jq '.total')
# 2. Pay it.
curl -X POST "$BASE/$DOMAIN_ID/renew" \
-H "Authorization: Bearer $SIMPLIFYD_WORKSPACE_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"years\":1,\"confirm_total\":$TOTAL}"const url = `${BASE}/${domainId}/renew`;
const headers = {
Authorization: `Bearer ${process.env.SIMPLIFYD_WORKSPACE_TOKEN}`,
'Content-Type': 'application/json',
};
// Asking without a price charges nothing and answers 409 with it.
const ask = await fetch(url, { method: 'POST', headers, body: JSON.stringify({ years: 1 }) });
const { total } = await ask.json();
if (total > 250_000_000) throw new Error('renewal costs more than ₦25,000');
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ years: 1, confirm_total: total }),
});
const { domain } = await response.json();_, err := domains.RenewWithOptions(ctx, domainID, cloud.RenewDomainOptions{Years: 1})
pce, ok := cloud.AsPriceConfirmation(err)
if !ok {
return err
}
if pce.Total > 250_000_000 {
return fmt.Errorf("renewal costs %s", cloud.FormatNaira(pce.Total))
}
renewed, err := domains.RenewWithOptions(ctx, domainID, cloud.RenewDomainOptions{Years: 1, ConfirmTotal: &pce.Total})Response
200 OK with the domain and its new expires_at:
{
"domain": {
"slug": "0192f3d4-1b2a-7c88-a1e0-5d6c7b8a9f01",
"name": "acme.io",
"status": "active",
"expires_at": "2028-10-14T09:30:00Z",
"auto_renew": true,
"registrant": { "...": "..." },
"created_at": "2026-10-14T09:30:00Z"
}
}If the registrar refuses the renewal, the wallet is refunded and the answer is 400 saying why. A domain still registering cannot be renewed yet.
Read the registrant
GET {BASE}/registrantThe legal owner every purchase is registered to. A token can read it but not change it: that is done by an owner or developer in the console, under Settings → Domains, or with edge domain registrant set.
curl "$BASE/registrant" -H "Authorization: Bearer $SIMPLIFYD_WORKSPACE_TOKEN"registrant, err := domains.Registrant(ctx) // nil when none is savedResponse
200 OK
{
"registrant": {
"first_name": "Ada",
"last_name": "Obi",
"company_name": "Acme Ltd",
"address1": "1 Marina",
"address2": "",
"city": "Lagos",
"state": "LA",
"zip": "101001",
"country": "NG",
"email": "ada@acme.ng",
"phone": "+2348012345678"
},
"updated_at": "2026-10-01T12:00:00Z"
}registrant is null until one is saved, and buying fails, charging nothing, until it is.
Errors
Error responses include a message describing the failure:
{
"type": "VALIDATION_ERROR",
"message": "your wallet is ₦4,200 short of the ₦18,920 this costs; top up and try again"
}| Status | Meaning |
|---|---|
400 | The request cannot be done as asked; message says why. |
401 | The token is missing, malformed, deleted, or its workspace was deleted. |
403 | The token is for another workspace, lacks the domains scope, tried something tokens cannot do (see workspace tokens), or its creator can no longer buy domains. |
409 | A price was not confirmed. The body carries the current total. |
5xx | Our side, or the registrar is unreachable. Nothing is charged for a request that fails before the registrar is asked; one that fails after is settled in the background. |