List domains in a space
GET
https://forumulate.com/api /orgs/{orgId}/spaces/{spaceId}/domains The custom hostnames mapped to this space with the verification and certificate state of each. Poll it while a domain is being set up; it is what tells you a name is ready to be activated.
Requires token scope: read (the coarse method gate) for Atlas key (ak_), Personal Access Token (cft_) or OAuth (cfo_) auth — in addition to any catalog scope (GET /pats/scopes) the route’s capability needs. Session/cookie auth is governed by RBAC capabilities instead.
Parameters
orgIdstringpathrequiredspaceIdstringpathrequiredforumulate-versionstringheaderAPI version negotiation. Send
forumulate-version: 2026-12-01 on every request from a new integration; it is optional only so that integrations written before versioning keep working. Each version includes everything before it:limitintegerqueryRows per page. Defaults to 100. 500 is the most this endpoint serves — a larger value is capped, not refused. A value that is not a whole number of 1 or more is a 422, never a silent default.
cursorstringqueryWhere to continue from: pass back the
page.nextCursor of the previous answer. Opaque — there is nothing inside one to construct or parse. Sending one also opts into the enveloped shape, exactly as stating Forumulate-Version does.Code samples
cURL
curl -X GET "https://forumulate.com/api/orgs/{orgId}/spaces/{spaceId}/domains?limit=&cursor=" \
-H "forumulate-version: <value>"TypeScript
const res = await fetch('https://forumulate.com/api/orgs/{orgId}/spaces/{spaceId}/domains?limit=&cursor=', {
method: 'GET',
headers: {
'forumulate-version': '<value>',
},
});
const data = await res.json();Python
import requests
res = requests.request(
"GET",
"https://forumulate.com/api/orgs/{orgId}/spaces/{spaceId}/domains?limit=&cursor=",
headers={
"forumulate-version": "<value>",
},
)
data = res.json()Go
package main
import (
"fmt"
"io"
"net/http"
)
func main() {
req, _ := http.NewRequest("GET", "https://forumulate.com/api/orgs/{orgId}/spaces/{spaceId}/domains?limit=&cursor=", nil)
req.Header.Set("forumulate-version", "<value>")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}PHP
<?php
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://forumulate.com/api/orgs/{orgId}/spaces/{spaceId}/domains?limit=&cursor=');
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['forumulate-version: <value>']);
$response = curl_exec($ch);
curl_close($ch);
echo $response;Ruby
require 'net/http'
require 'uri'
uri = URI('https://forumulate.com/api/orgs/{orgId}/spaces/{spaceId}/domains?limit=&cursor=')
req = Net::HTTP::Get.new(uri)
req['forumulate-version'] = '<value>'
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
http.request(req)
end
puts res.bodyJava
import java.net.URI;
import java.net.http.*;
import java.net.http.HttpRequest.BodyPublishers;
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://forumulate.com/api/orgs/{orgId}/spaces/{spaceId}/domains?limit=&cursor="))
.header("forumulate-version", "<value>")
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());Responses
200 Every domain attached to this space, with each one's verification state.
Example
{
"domains": [
{
"id": "string",
"hostname": "string",
"basePath": "string",
"verified": true,
"active": true,
"alsoRedirectWww": true,
"ipAllowList": [
"string"
],
"redirectTo": "string",
"verificationRecord": {
"name": null,
"value": null
},
"verificationFile": {
"path": null,
"contents": null
},
"verificationMeta": {
"tag": "string"
},
"dnsTarget": {
"type": null,
"name": null,
"value": null
},
"createdAt": "2024-01-01T00:00:00Z"
}
],
"page": {
"limit": 0,
"hasMore": true,
"nextCursor": "string",
"has_more": true,
"next_cursor": "string",
"total": 0
},
"data": [
{
"id": "string",
"hostname": "string",
"basePath": "string",
"verified": true,
"active": true,
"alsoRedirectWww": true,
"ipAllowList": [
"string"
],
"redirectTo": "string",
"verificationRecord": {
"name": null,
"value": null
},
"verificationFile": {
"path": null,
"contents": null
},
"verificationMeta": {
"tag": "string"
},
"dnsTarget": {
"type": null,
"name": null,
"value": null
},
"createdAt": "2024-01-01T00:00:00Z"
}
]
}Schema
domainsDomainSummaryDto[]requiredShow child parameters
idstringrequiredhostnamestringrequiredbasePathstringrequiredThe path this domain publishes under, or '' for the whole hostname.
verifiedbooleanrequiredOwnership has been proven.
activebooleanrequiredTrue once we ANSWER this address. Separate from
verified: POST {domainId}/verify {"activate": false} proves ownership without switching traffic, so your own application keeps serving the path until POST {domainId}/activate.alsoRedirectWwwbooleanrequiredAlso answer
www.<hostname> (same path) with a permanent redirect to this address, written into our proxy. Default false: a domain is registered for ONE hostname, so without it www.acme.com/docs reaches whatever else answers that name (often your own app, which 404s). Leave it off when your own app already redirects www; GET …/setup reports wwwNotCovered either way. Refused (422) on a hostname that already starts with www.. Point the www name’s DNS where the bare name points; for a whole-host domain we also request its certificate.ipAllowListstring[]requiredThe networks (addresses and CIDR blocks) that may read this address;
[] means anywhere. Set with PUT {domainId}/ip-allow-list.redirectTostringrequiredWhen set, every request on this address answers 301 to this URL plus its path; null while it serves. Set with
PUT {domainId}/redirect.verificationRecordDnsRecordDtorequiredThe TXT record that proves ownership.
Show child parameters
namestringrequiredvaluestringrequiredverificationFileVerificationFileDtorequiredA file to place on the site as an alternative ownership proof.
Show child parameters
pathstringrequiredcontentsstringrequiredverificationMetaobjectrequiredShow child parameters
tagstringrequiredA meta tag to place on the domain’s home page as proof of ownership.
dnsTargetDnsTargetDtorequiredThe DNS record to create so traffic reaches us (CNAME for a subdomain, A for an apex); null when the deployment configures no target.
Show child parameters
typestringrequirednamestringrequiredThe record name to create (the hostname).
valuestringrequiredThe record value — our edge host (CNAME) or IP (A).
createdAtstringrequiredpageobjectThe paging envelope a collection carries when the caller states
Forumulate-Version. Each operation’s own response schema says whether it has one: a collection either carries page, or answers both this and its pre-envelope shape and picks by the header. No listing pages with keys of its own any more — the allowlists in openapi-collection-shape.test.ts are empty, which is what stops a new one from appearing. Read hasMore and nextCursor: the snake_case pair beside them is deprecated and is not sent to a caller who states 2026-11-01 or later.Show child parameters
limitintegerrequiredThe limit this page was built with — what was asked for, capped. NOT the number of rows returned: a final page under the limit is normal, and
hasMore is the only thing that says whether to keep walking.hasMorebooleanrequiredWhether another page follows this one.
nextCursorstringrequiredPass back as
cursor to continue. Null on the last page.has_morebooleandeprecatedThe same value as
hasMore. These two are the only snake_case keys in the API. Sent to callers who state 2026-09-01 or no version at all; ABSENT from 2026-11-01 onwards. Read hasMore instead — it is sent on every version.next_cursorstringdeprecatedThe same value as
nextCursor. Absent from 2026-11-01 onwards; read nextCursor.totalintegerRows in the whole collection — the size of the set, not of this page, and not of what you have seen so far. ABSENT where the source cannot count the collection: the append-only logs (staff actions, automation runs, connector activity, ingested questions, chat history) grow without bound and are not counted on every page view. Walk on
hasMore, which is exact on every answer; read total only to show a size, and handle its absence.dataDomainSummaryDto[]The same rows as the key named after them (
topics, articles, pages, monitors, …), under a key that is the same on every paged list — so one parser reads every collection. The SAME array, not a different selection: read either. Present whenever page is, i.e. when you state Forumulate-Version (or send a cursor); the unversioned shape does not carry it.Show child parameters
idstringrequiredhostnamestringrequiredbasePathstringrequiredThe path this domain publishes under, or '' for the whole hostname.
verifiedbooleanrequiredOwnership has been proven.
activebooleanrequiredTrue once we ANSWER this address. Separate from
verified: POST {domainId}/verify {"activate": false} proves ownership without switching traffic, so your own application keeps serving the path until POST {domainId}/activate.alsoRedirectWwwbooleanrequiredAlso answer
www.<hostname> (same path) with a permanent redirect to this address, written into our proxy. Default false: a domain is registered for ONE hostname, so without it www.acme.com/docs reaches whatever else answers that name (often your own app, which 404s). Leave it off when your own app already redirects www; GET …/setup reports wwwNotCovered either way. Refused (422) on a hostname that already starts with www.. Point the www name’s DNS where the bare name points; for a whole-host domain we also request its certificate.ipAllowListstring[]requiredThe networks (addresses and CIDR blocks) that may read this address;
[] means anywhere. Set with PUT {domainId}/ip-allow-list.redirectTostringrequiredWhen set, every request on this address answers 301 to this URL plus its path; null while it serves. Set with
PUT {domainId}/redirect.verificationRecordDnsRecordDtorequiredThe TXT record that proves ownership.
Show child parameters
namestringrequiredvaluestringrequiredverificationFileVerificationFileDtorequiredA file to place on the site as an alternative ownership proof.
Show child parameters
pathstringrequiredcontentsstringrequiredverificationMetaobjectrequiredShow child parameters
tagstringrequiredA meta tag to place on the domain’s home page as proof of ownership.
dnsTargetDnsTargetDtorequiredThe DNS record to create so traffic reaches us (CNAME for a subdomain, A for an apex); null when the deployment configures no target.
Show child parameters
typestringrequirednamestringrequiredThe record name to create (the hostname).
valuestringrequiredThe record value — our edge host (CNAME) or IP (A).
createdAtstringrequired400 The request could not be understood — a malformed header, path or query value (
request.malformed). A value that was understood but is not acceptable is a 422 validation.failed.Example
{
"error": {
"code": "string",
"message": "string",
"details": {},
"docs": "string",
"reason": "string",
"slugs": [
"string"
],
"conflict": {
"etag": "string",
"ops": [
null
]
}
},
"requestId": "string"
}Schema
errorobjectrequiredShow child parameters
codestringrequiredStable, machine-readable error code, e.g.
rbac.forbidden.messagestringrequiredHuman-readable explanation.
detailsobjectField-level validation problems, keyed by field path (422 only).
docsstringWhere the vocabulary or format this error is about is published — an absolute URL, or an API route such as
GET /space-types/{type}/presets. Present on errors over a controlled list, so the answer to "what am I allowed to send?" is in the error itself.reasonstringWhy a credential was refused, as one stable word, where one status has several causes — e.g. an external_jwt redeem answers
iat_too_old, iat_in_future, bad_signature, bad_issuer, bad_audience, expired and more. Branch on this, not on message.slugsstring[]The exact slugs that collided, on a slug-conflict 409 —
space.slug_taken, page.slug_taken, collection.slug_taken, site.slug_taken or badge.slug_taken.conflictobjectOn a 409 from a content entry field write (
PATCH …/entries/{entryId}/fields) whose If-Match was stale.Show child parameters
etagstringrequiredThe entry's current fields etag.
opsarrayrequiredThe field operations since your
If-Match that touch a path your own operations touch. Empty: none did — retry the batch unchanged under etag. null: the history no longer reaches your tag — re-read the entry first.requestIdstringThe id of this request — the same value as the
x-request-id and x-forumulate-request-id response headers. Quote it in a bug report: it is the only thing that finds your exact request in our logs. It is in the body because that is the part that survives — an SDK that throws the parsed body, a serverless log that keeps the body and drops the headers, a ticket with the JSON pasted in.401 No credential, or one that is expired or revoked (
auth.required). Send a Bearer token or the session cookie.Example
{
"error": {
"code": "string",
"message": "string",
"details": {},
"docs": "string",
"reason": "string",
"slugs": [
"string"
],
"conflict": {
"etag": "string",
"ops": [
null
]
}
},
"requestId": "string"
}Schema
errorobjectrequiredShow child parameters
codestringrequiredStable, machine-readable error code, e.g.
rbac.forbidden.messagestringrequiredHuman-readable explanation.
detailsobjectField-level validation problems, keyed by field path (422 only).
docsstringWhere the vocabulary or format this error is about is published — an absolute URL, or an API route such as
GET /space-types/{type}/presets. Present on errors over a controlled list, so the answer to "what am I allowed to send?" is in the error itself.reasonstringWhy a credential was refused, as one stable word, where one status has several causes — e.g. an external_jwt redeem answers
iat_too_old, iat_in_future, bad_signature, bad_issuer, bad_audience, expired and more. Branch on this, not on message.slugsstring[]The exact slugs that collided, on a slug-conflict 409 —
space.slug_taken, page.slug_taken, collection.slug_taken, site.slug_taken or badge.slug_taken.conflictobjectOn a 409 from a content entry field write (
PATCH …/entries/{entryId}/fields) whose If-Match was stale.Show child parameters
etagstringrequiredThe entry's current fields etag.
opsarrayrequiredThe field operations since your
If-Match that touch a path your own operations touch. Empty: none did — retry the batch unchanged under etag. null: the history no longer reaches your tag — re-read the entry first.requestIdstringThe id of this request — the same value as the
x-request-id and x-forumulate-request-id response headers. Quote it in a bug report: it is the only thing that finds your exact request in our logs. It is in the body because that is the part that survives — an SDK that throws the parsed body, a serverless log that keeps the body and drops the headers, a ticket with the JSON pasted in.403 Authenticated, and not allowed — always
rbac.forbidden, whether what is missing is the capability, the token scope, or a resource the token is pinned away from. The message names the capability and, for a scope refusal, the scopes that would grant it. A refusal because of the PLAN is a 402 with quota.exceeded, not this.Example
{
"error": {
"code": "string",
"message": "string",
"details": {},
"docs": "string",
"reason": "string",
"slugs": [
"string"
],
"conflict": {
"etag": "string",
"ops": [
null
]
}
},
"requestId": "string"
}Schema
errorobjectrequiredShow child parameters
codestringrequiredStable, machine-readable error code, e.g.
rbac.forbidden.messagestringrequiredHuman-readable explanation.
detailsobjectField-level validation problems, keyed by field path (422 only).
docsstringWhere the vocabulary or format this error is about is published — an absolute URL, or an API route such as
GET /space-types/{type}/presets. Present on errors over a controlled list, so the answer to "what am I allowed to send?" is in the error itself.reasonstringWhy a credential was refused, as one stable word, where one status has several causes — e.g. an external_jwt redeem answers
iat_too_old, iat_in_future, bad_signature, bad_issuer, bad_audience, expired and more. Branch on this, not on message.slugsstring[]The exact slugs that collided, on a slug-conflict 409 —
space.slug_taken, page.slug_taken, collection.slug_taken, site.slug_taken or badge.slug_taken.conflictobjectOn a 409 from a content entry field write (
PATCH …/entries/{entryId}/fields) whose If-Match was stale.Show child parameters
etagstringrequiredThe entry's current fields etag.
opsarrayrequiredThe field operations since your
If-Match that touch a path your own operations touch. Empty: none did — retry the batch unchanged under etag. null: the history no longer reaches your tag — re-read the entry first.requestIdstringThe id of this request — the same value as the
x-request-id and x-forumulate-request-id response headers. Quote it in a bug report: it is the only thing that finds your exact request in our logs. It is in the body because that is the part that survives — an SDK that throws the parsed body, a serverless log that keeps the body and drops the headers, a ticket with the JSON pasted in.404 No such thing — one of the ids in the path does not resolve, or resolves to something this caller may not see (
*.not_found).Example
{
"error": {
"code": "string",
"message": "string",
"details": {},
"docs": "string",
"reason": "string",
"slugs": [
"string"
],
"conflict": {
"etag": "string",
"ops": [
null
]
}
},
"requestId": "string"
}Schema
errorobjectrequiredShow child parameters
codestringrequiredStable, machine-readable error code, e.g.
rbac.forbidden.messagestringrequiredHuman-readable explanation.
detailsobjectField-level validation problems, keyed by field path (422 only).
docsstringWhere the vocabulary or format this error is about is published — an absolute URL, or an API route such as
GET /space-types/{type}/presets. Present on errors over a controlled list, so the answer to "what am I allowed to send?" is in the error itself.reasonstringWhy a credential was refused, as one stable word, where one status has several causes — e.g. an external_jwt redeem answers
iat_too_old, iat_in_future, bad_signature, bad_issuer, bad_audience, expired and more. Branch on this, not on message.slugsstring[]The exact slugs that collided, on a slug-conflict 409 —
space.slug_taken, page.slug_taken, collection.slug_taken, site.slug_taken or badge.slug_taken.conflictobjectOn a 409 from a content entry field write (
PATCH …/entries/{entryId}/fields) whose If-Match was stale.Show child parameters
etagstringrequiredThe entry's current fields etag.
opsarrayrequiredThe field operations since your
If-Match that touch a path your own operations touch. Empty: none did — retry the batch unchanged under etag. null: the history no longer reaches your tag — re-read the entry first.requestIdstringThe id of this request — the same value as the
x-request-id and x-forumulate-request-id response headers. Quote it in a bug report: it is the only thing that finds your exact request in our logs. It is in the body because that is the part that survives — an SDK that throws the parsed body, a serverless log that keeps the body and drops the headers, a ticket with the JSON pasted in.429 Rate limited — see the RateLimit-* headers.
Example
{
"error": {
"code": "string",
"message": "string",
"details": {},
"docs": "string",
"reason": "string",
"slugs": [
"string"
],
"conflict": {
"etag": "string",
"ops": [
null
]
}
},
"requestId": "string"
}Schema
errorobjectrequiredShow child parameters
codestringrequiredStable, machine-readable error code, e.g.
rbac.forbidden.messagestringrequiredHuman-readable explanation.
detailsobjectField-level validation problems, keyed by field path (422 only).
docsstringWhere the vocabulary or format this error is about is published — an absolute URL, or an API route such as
GET /space-types/{type}/presets. Present on errors over a controlled list, so the answer to "what am I allowed to send?" is in the error itself.reasonstringWhy a credential was refused, as one stable word, where one status has several causes — e.g. an external_jwt redeem answers
iat_too_old, iat_in_future, bad_signature, bad_issuer, bad_audience, expired and more. Branch on this, not on message.slugsstring[]The exact slugs that collided, on a slug-conflict 409 —
space.slug_taken, page.slug_taken, collection.slug_taken, site.slug_taken or badge.slug_taken.conflictobjectOn a 409 from a content entry field write (
PATCH …/entries/{entryId}/fields) whose If-Match was stale.Show child parameters
etagstringrequiredThe entry's current fields etag.
opsarrayrequiredThe field operations since your
If-Match that touch a path your own operations touch. Empty: none did — retry the batch unchanged under etag. null: the history no longer reaches your tag — re-read the entry first.requestIdstringThe id of this request — the same value as the
x-request-id and x-forumulate-request-id response headers. Quote it in a bug report: it is the only thing that finds your exact request in our logs. It is in the body because that is the part that survives — an SDK that throws the parsed body, a serverless log that keeps the body and drops the headers, a ticket with the JSON pasted in.default Error — the stable { error: { code, message } } envelope.
Example
{
"error": {
"code": "string",
"message": "string",
"details": {},
"docs": "string",
"reason": "string",
"slugs": [
"string"
],
"conflict": {
"etag": "string",
"ops": [
null
]
}
},
"requestId": "string"
}Schema
errorobjectrequiredShow child parameters
codestringrequiredStable, machine-readable error code, e.g.
rbac.forbidden.messagestringrequiredHuman-readable explanation.
detailsobjectField-level validation problems, keyed by field path (422 only).
docsstringWhere the vocabulary or format this error is about is published — an absolute URL, or an API route such as
GET /space-types/{type}/presets. Present on errors over a controlled list, so the answer to "what am I allowed to send?" is in the error itself.reasonstringWhy a credential was refused, as one stable word, where one status has several causes — e.g. an external_jwt redeem answers
iat_too_old, iat_in_future, bad_signature, bad_issuer, bad_audience, expired and more. Branch on this, not on message.slugsstring[]The exact slugs that collided, on a slug-conflict 409 —
space.slug_taken, page.slug_taken, collection.slug_taken, site.slug_taken or badge.slug_taken.conflictobjectOn a 409 from a content entry field write (
PATCH …/entries/{entryId}/fields) whose If-Match was stale.Show child parameters
etagstringrequiredThe entry's current fields etag.
opsarrayrequiredThe field operations since your
If-Match that touch a path your own operations touch. Empty: none did — retry the batch unchanged under etag. null: the history no longer reaches your tag — re-read the entry first.requestIdstringThe id of this request — the same value as the
x-request-id and x-forumulate-request-id response headers. Quote it in a bug report: it is the only thing that finds your exact request in our logs. It is in the body because that is the part that survives — an SDK that throws the parsed body, a serverless log that keeps the body and drops the headers, a ticket with the JSON pasted in.The Forumulate teamUpdated