Connecting
The MCP server is at https://mcp.spaicely.com/mcp (Streamable HTTP, stateless). Clients find the authorization server through the protected resource metadata, register themselves with dynamic client registration, or use the URL of a client ID metadata document as their client_id, and sign in with the authorization code flow and PKCE (S256). There are no client secrets. On the consent screen the person chooses the account, the sites and the scopes; a token never allows more than that person's own role on a site.
Domain search needs no account: https://mcp.spaicely.com/mcp/public, or the HTTP API below.
Claude Code
Add the server for yourself with claude mcp add --transport http spaicely https://mcp.spaicely.com/mcp, then run /mcp and sign in. On a machine without a browser: claude mcp login spaicely --no-browser, which prints a URL to open elsewhere.
For everyone working on a project, put it in .mcp.json at the root of the repository. Each person signs in with their own account. Keep "type": "http": without it Claude Code skips the entry.
{
"mcpServers": {
"spaicely": { "type": "http", "url": "https://mcp.spaicely.com/mcp" }
}
}
Or install the plugin, which brings the server and our guides for building and running a site and finding a domain in one go. The prompts are then called /mcp__plugin_spaicely_spaicely__new_site and so on. Source: github.com/spaicely/agent-plugins.
claude plugin marketplace add spaicely/agent-plugins claude plugin install spaicely@spaicely
The server brings prompts that work as commands: /mcp__spaicely__new_site builds and publishes a new site, /mcp__spaicely__launch_check goes over a site before launch. Deleting a site, registering or moving a domain, changing nameservers, removing a form and changing the people on an account always ask for your approval, even in auto mode. sites.files.get returns up to 500,000 characters in the conversation; larger answers Claude Code keeps in a file.
Codex and Gemini CLI
The same plugin works in Codex and Gemini CLI, with the server and the three guides. Both sign in with OAuth in the browser on first use.
codex plugin marketplace add spaicely/agent-plugins gemini extensions install https://github.com/spaicely/agent-plugins
In Codex, install Spaicely from the plugin list afterwards. In Gemini CLI, run /mcp auth spaicely to sign in and gemini extensions update spaicely for a newer version. Without the plugin, add the server in Gemini CLI's settings.json as "spaicely": {"httpUrl": "https://mcp.spaicely.com/mcp"} under mcpServers.
What the server tells every client: Spaicely hosts static websites on the customer's own domain in the EU. You act for one person and one account; start with account.whoami to learn which sites you may see and what you may change; with no site yet, sites.create makes one on that account. Your rights are never wider than the person's role on a site. Purchases always need a second call with a confirmation token, after the person agreed. The person chooses per kind of action whether you may do it yourself, must ask first, or may not (account.whoami lists them as ai_permissions). A call that must be asked first answers with approval_required and a url: give the person that link, do not repeat the call, and read the outcome with approvals.status once they say they decided. The call runs exactly as you sent it. Give every site its own 404.html at the root, in the style and language of the site: visitors get it for any address that does not exist. Without one they get a plain page of ours in the language of the lang attribute of index.html. Every site also needs robots.txt, sitemap.xml and llms.txt at the root, an icon, and on each page its own title, meta description, lang, canonical link and Open Graph tags for link previews, all with full https URLs on the site's domain; describe the business in JSON-LD. The full checklist with examples: https://spaicely.com/skills/en/site-building/SKILL.md (also nl, de, fr). Running a site (versions, rollback, domain, DNS, mail forwarding, people, plan): https://spaicely.com/skills/en/site-management/SKILL.md. After publishing, the hints in sites.versions say what is still missing. Once the hosting is paid, a published site is also at its own address (address in account.whoami), to share before a domain is connected. A static site can still take messages, signups and payments: scripts.enable adds a form handled by Spaicely and gives the HTML to put in a page. Kinds: contact (mail to the owner), signup (a list with double opt-in), download (a file under _private/ for the visitor's details), coupon (a discount code; one for everyone, a list, or codes with dates such as a code per week) and donation (any plan: amounts paid to the owner's own Stripe account, a progress bar from progress_url). A download or coupon form can take a price. Use these instead of a form or payment service elsewhere. A site with the Shop plan sells through shop.setup: Stripe Checkout on the owner's own Stripe account, prices from a products file in the site (at most 500 products). Payments carry a 2% commission. A person who may change files but not publish asks for approval with sites.review.request (Pro and Shop plans); sites.stats gives visits without cookies. A partner proposes a domain to a customer with domains.propose and hands a finished site over with sites.handover. sites.subscription shows and starts or switches a site's hosting plan; account.billing holds the billing details every purchase needs. Mail to a connected domain can be forwarded to mailboxes the person already has (info@ to their Gmail): mail.forward.enable, then mail.forward.add; EUR 1.50 (GBP 1.30, USD 1.75) a month per address, Site 1, Pro 5, Shop 10. Answering as info@ is not possible yet. The prompts new_site and launch_check describe how we like a site built and checked; move_site rebuilds a site from elsewhere, such as WordPress, with the same pages and addresses. Spaicely adds tools over time. Fetch the tool list again when you can; when a tool named here or on https://spaicely.com/developers/ is not in your list, ask the person to reconnect the Spaicely connector (in claude.ai: Settings > Connectors), which loads the current list.
Connecting from code
Every MCP SDK can talk to Spaicely; you don't need any software from us. The examples search for a domain on the public server, which needs no account, and then show how to send an access token to https://mcp.spaicely.com/mcp, the signed-in server. Each one was run against our server before it went on this page.
TypeScript
npm install @modelcontextprotocol/sdk
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const client = new Client({ name: "my-app", version: "1.0.0" });
await client.connect(new StreamableHTTPClientTransport(new URL("https://mcp.spaicely.com/mcp/public")));
const result = await client.callTool({
name: "domains.search",
arguments: { names: ["bakkerijjansen"], extensions: ["nl", "com"] },
});
for (const found of result.structuredContent.results) console.log(found.domain, found.status);
await client.close();
// signed in: the access token as a header
new StreamableHTTPClientTransport(new URL("https://mcp.spaicely.com/mcp"), {
requestInit: { headers: { Authorization: `Bearer ${token}` } },
});
Node.js 18 or newer, Deno or Bun; the same code runs in a browser.
Python
pip install "mcp>=2"
import asyncio
from mcp import Client
async def main():
async with Client("https://mcp.spaicely.com/mcp/public") as client:
result = await client.call_tool("domains.search", {"names": ["bakkerijjansen"], "extensions": ["nl", "com"]})
for found in result.structured_content["results"]:
print(found["domain"], found["status"])
asyncio.run(main())
# signed in: the access token as a header
from mcp.client.streamable_http import create_mcp_http_client, streamable_http_client
http = create_mcp_http_client(headers={"Authorization": f"Bearer {token}"})
async with Client(streamable_http_client("https://mcp.spaicely.com/mcp", http_client=http)) as client:
...
Python 3.10 or newer.
Go
go get github.com/modelcontextprotocol/go-sdk
package main
import (
"context"
"fmt"
"log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func main() {
ctx := context.Background()
client := mcp.NewClient(&mcp.Implementation{Name: "my-app", Version: "1.0.0"}, nil)
session, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: "https://mcp.spaicely.com/mcp/public"}, nil)
if err != nil {
log.Fatal(err)
}
defer session.Close()
result, err := session.CallTool(ctx, &mcp.CallToolParams{
Name: "domains.search",
Arguments: map[string]any{"names": []string{"bakkerijjansen"}, "extensions": []string{"nl", "com"}},
})
if err != nil {
log.Fatal(err)
}
for _, found := range result.StructuredContent.(map[string]any)["results"].([]any) {
domain := found.(map[string]any)
fmt.Println(domain["domain"], domain["status"])
}
}
// signed in: an http.Client that adds the access token
type bearer struct{ token string }
func (b bearer) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set("Authorization", "Bearer "+b.token)
return http.DefaultTransport.RoundTrip(req)
}
transport := &mcp.StreamableClientTransport{
Endpoint: "https://mcp.spaicely.com/mcp",
HTTPClient: &http.Client{Transport: bearer{token}},
}
Go 1.25 or newer.
Rust
cargo add rmcp --features client,transport-streamable-http-client-reqwest,reqwest cargo add tokio --features full cargo add serde_json
use rmcp::{
ServiceExt,
model::CallToolRequestParams,
transport::{StreamableHttpClientTransport, streamable_http_client::StreamableHttpClientTransportConfig},
};
use serde_json::json;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = StreamableHttpClientTransportConfig::with_uri("https://mcp.spaicely.com/mcp/public");
let client = ().serve(StreamableHttpClientTransport::from_config(config)).await?;
let arguments = json!({ "names": ["bakkerijjansen"], "extensions": ["nl", "com"] });
let result = client
.call_tool(CallToolRequestParams::new("domains.search").with_arguments(arguments.as_object().unwrap().clone()))
.await?;
for found in result.structured_content.unwrap()["results"].as_array().unwrap() {
println!("{} {}", found["domain"].as_str().unwrap(), found["status"].as_str().unwrap());
}
client.cancel().await?;
Ok(())
}
// signed in: the access token, without "Bearer "
let config = StreamableHttpClientTransportConfig::with_uri("https://mcp.spaicely.com/mcp").auth_header(token);
The reqwest feature brings TLS; without it only http:// works.
Ruby
bundle add mcp faraday base64
require "mcp"
client = MCP::Client.new(transport: MCP::Client::HTTP.new(url: "https://mcp.spaicely.com/mcp/public"))
client.connect(client_info: { name: "my-app", version: "1.0.0" })
response = client.call_tool(name: "domains.search", arguments: { names: ["bakkerijjansen"], extensions: ["nl", "com"] })
response.dig("result", "structuredContent", "results").each do |found|
puts "#{found["domain"]} #{found["status"]}"
end
# signed in: the access token as a header
transport = MCP::Client::HTTP.new(url: "https://mcp.spaicely.com/mcp",
headers: { "Authorization" => "Bearer #{token}" })
The mcp gem needs faraday for its HTTP client, and base64 since Ruby 3.4.
curl
# 1. which tools are there
curl https://mcp.spaicely.com/mcp/public \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'
# 2. call one
curl https://mcp.spaicely.com/mcp/public \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": "domains.search", "arguments": {"names": ["bakkerijjansen"], "extensions": ["nl", "com"]}}}'
# signed in: same requests to /mcp, with the access token curl https://mcp.spaicely.com/mcp -H "Authorization: Bearer $TOKEN" ...
The server is stateless: no session, every POST stands on its own.
An access token comes from the OAuth flow described under Calling MCP without a client library; the TypeScript and Python SDKs can run that flow themselves, the TypeScript one through an authProvider on the transport. There are official SDKs for C#, Java, Kotlin, Swift and PHP too, see modelcontextprotocol.io; they work the same way: a Streamable HTTP transport to the address above, then tools/call.
Putting a site live
Spaicely hosts what the AI built; it does not build or template anything. A new version is the full list of files with their SHA-256 (sites.deploy); the answer names the files we do not have yet, which go up with sites.files.put. An AI with the site as a folder on disk calls sites.upload_url and sends one tar.gz instead. Nothing is visible until sites.publish, which switches all visitors at once and can roll back.
We keep the files exactly as uploaded; sites.files.get returns them that way. Visitors get an optimised copy: HTML without comments and indentation, CSS and JavaScript minified, large JPEG and PNG images recompressed and scaled to at most 2560 pixels. A file that would not get smaller goes out as it is. sites.versions shows the savings and hints, each with a fix: images without alt or without a size, and what the site still lacks, from sitemap.xml, robots.txt and llms.txt to a page without its own title, description, canonical link or Open Graph tags.
What we expect of every site, with examples of the head of a page, the sitemap, link previews, structured data and more than one language, is in the skill site-building (also in Dutch, German and French). Give it to the AI that builds the site, or add it to Claude Code as a skill.
How to run a site once it is built (versions and rollback, changing one block of a page, connecting the domain, DNS, mail forwarding, people and roles, approval, handover, the hosting plan) is in the skill site-management (also in Dutch, German and French). The Claude Code plugin brings both skills and the domain search guide.
What the AI may do on its own
Reading is always allowed. For every kind of action that changes something (building, publishing, domain and DNS, forms and shop, visitor data, mail, buying and plans, people and roles, the domain at the registry, deleting, billing details) the person chooses self, ask or never at app.spaicely.com/ai; an account owner can set a minimum for everyone. account.whoami returns these settings as ai_permissions. By default buying, people, mail, the registry, deleting and billing details are ask.
A call whose kind is set to ask does not run. It answers with approval_required, an approval id and a url for the person, who approves in Spaicely with a passkey or a recent sign-in, optionally with "don't ask me again" for that kind. We then run the call exactly as it was sent, with the same token, and approvals.status returns its answer. Do not repeat the call: the same call gives the same approval. A request waits 24 hours; a never kind is refused.
Command line
The Spaicely CLI (open source, MIT) is for people who build their site themselves, with Jekyll, Hugo, Eleventy, Astro or plain HTML, and for AI coding tools that run shell commands, such as Claude Code and Cursor. It is an MCP client of the same server as the AI assistants, so it can do what they can do, with the same rights. Every tool below works through spaicely call, also the ones without a command of their own.
Install
One file, no runtime needed. Every release has binaries for macOS (Apple silicon and Intel), Linux (x64 and arm64) and Windows (x64), a SHA256SUMS file and a build attestation.
macOS
brew install spaicely/tap/spaicely # or without Homebrew: picks the right binary, checks its SHA-256, puts it in ~/.local/bin curl -fsSL https://spaicely.com/install.sh | sh
Linux
curl -fsSL https://spaicely.com/install.sh | sh # another folder than ~/.local/bin curl -fsSL https://spaicely.com/install.sh | SPAICELY_INSTALL_DIR=/usr/local/bin sh # Homebrew on Linux works too brew install spaicely/tap/spaicely
Windows
In PowerShell, without administrator rights:
$dir = "$env:LOCALAPPDATA\Programs\spaicely"
$zip = "$env:TEMP\spaicely.zip"
$url = "https://github.com/spaicely/cli/releases/latest/download/spaicely-windows-x64.zip"
Invoke-WebRequest $url -OutFile $zip
Expand-Archive $zip -DestinationPath $dir -Force
$path = [Environment]::GetEnvironmentVariable("Path", "User")
[Environment]::SetEnvironmentVariable("Path", "$path;$dir", "User")
Open a new terminal afterwards so it sees the new path. In WSL, use the Linux line above.
With Node.js, on any system
npm install -g spaicely # Node.js 20 or newer npx spaicely whoami # or run it without installing
Check, verify, update
spaicely --version # the download is the build from the repository (needs the GitHub CLI) gh attestation verify spaicely-darwin-arm64.tar.gz --repo spaicely/cli brew upgrade spaicely # Homebrew npm install -g spaicely@latest # npm curl -fsSL https://spaicely.com/install.sh | sh # the script installs the newest release again
Sign in
spaicely login # opens the browser: the same consent screen as for an AI spaicely login --no-browser # over SSH: prints the address to open elsewhere spaicely whoami # who you are, which account, which sites spaicely logout # revokes the sign-in on the server and forgets it here
The sign-in is kept in ~/.config/spaicely/credentials.json, readable only by you. Messages follow the language of your Spaicely account: Dutch, English, German or French.
From folder to live site
spaicely sites create "Bakkerij Jansen" # a new, empty site; or `spaicely sites` for the ones you have cd bakkerij-jansen spaicely init --site 12 # writes spaicely.toml; recognises Jekyll, Hugo, Eleventy, Astro and Vite spaicely serve --build # http://localhost:4000, served the way Spaicely will serve it spaicely deploy --dry-run # what would change compared to the live site spaicely deploy # build, then upload only what changed, as a new version spaicely publish # put the newest version live; asks first spaicely versions # every version, with the one that is live spaicely rollback # back to the version that was live before
spaicely.toml holds what init found; adjust it as you like:
site = 12 # or "bakkerijjansen.nl" dir = "_site" # the folder that goes up build = "bundle exec jekyll build" # runs before deploy and serve --build ignore = ["*.map", "drafts/**"]
In one go, without questions: spaicely deploy --publish --yes. --no-build skips the build, --force uploads even when nothing changed, -d uploads another folder.
Domains and DNS
spaicely domains search bakkerijjansen -e nl,com,eu # no sign-in needed spaicely domains search bakkerijjansen.nl --verify # also asks the registry spaicely domains register bakkerijjansen.nl # shows the price and asks first spaicely domains transfer bakkerijjansen.nl # opens the browser; the transfer code never touches the terminal spaicely domains connect bakkerijjansen.nl --site 12 # shows the records that come along, then the nameservers to set spaicely domains status bakkerijjansen.nl # where the connection stands spaicely dns ls bakkerijjansen.nl spaicely dns add bakkerijjansen.nl TXT @ "google-site-verification=..." spaicely dns add bakkerijjansen.nl MX @ mail.example.com --priority 10 spaicely dns rm bakkerijjansen.nl 31631305 # the number from dns ls
The first registration asks for the registrant's details once: --holder-name, --organization, --email, --phone (with country code), --street, --postal-code, --city and --country. The domain is always registered in the name of your account.
Any tool, and scripts
spaicely tools # every tool the server offers
spaicely call sites.files.list '{"site_id": 12, "prefix": "blog/"}'
spaicely call scripts.enable '{"site_id": 12, "kind": "contact", "name": "Contact"}'
# --json before any command gives machine-readable output
spaicely --json versions | jq '.live'
Exit codes: 0 done, 1 failed, 2 wrong use, 3 not signed in, 5 conflict while publishing. In Windows PowerShell, put the JSON for call in single quotes as well.
In CI
With SPAICELY_TOKEN set, the CLI sends that token instead of the stored sign-in. Make a key for CI in the dashboard under Connected AIs: for one account or one site, it can upload and publish (sites:read content:write sites:publish), works for a year and is shown once. Store it as the secret SPAICELY_TOKEN.
# .github/workflows/deploy.yml
- run: npx spaicely deploy --publish --yes
env:
SPAICELY_TOKEN: ${{ secrets.SPAICELY_TOKEN }}
All commands
| Command | What it does |
|---|---|
login · logout · whoami | Sign in through the browser (--no-browser), forget it, show who you are |
sites · sites create <name> | The sites you can work on; make a new, empty one |
init | Write spaicely.toml for this folder (-s site) |
serve [dir] | Show the site on this machine (-p port, --build) |
deploy | Build and upload a new version (-s, -d, --no-build, --dry-run, --force, --publish, -y) |
publish [version] · rollback · versions | Put a version live, go back one, list them (-s, -y) |
domains search <names...> | Is a name free? (-e extensions, --verify); no sign-in |
domains register · transfer | Register a domain, or move one to Spaicely |
domains connect · status · disconnect | Put a site on its domain (--skip, -y), see where it stands, take it off |
dns ls · dns add · dns rm | The DNS records Spaicely serves for a domain |
tools · call <tool> [json] | Every tool on the server, and calling any of them |
spaicely help <command> shows every option of a command.
Regions: who may change what
A role can be limited to actions on paths (create, update, delete, with patterns such as blog/**) and, on a page, to marked regions. A region is a pair of HTML comments around a block:
<!-- spaicely:edit opening-hours --> <p>Monday to Friday 8:00 to 18:00</p> <!-- /spaicely:edit --> <!-- spaicely:append news --> <article>…</article> <!-- /spaicely:append -->
In an edit region anything may change; in an append region only additions are allowed, so what is there stays. Names are lowercase letters, digits, - and _. Someone limited to regions may not change anything else on the page, the markers included. sites.files.list shows the regions of each page, and sites.region.update changes one region on top of the live version without sending the rest of the site.
When someone else published while a version was being made, publishing merges: files only this version changed come along, and a page both changed takes this version's regions, as long as the other version left those regions alone. Anything else is a conflict, reported per path; read the live version and make the change again.
Forms
A static site can still take messages and signups. scripts.enable adds a form that Spaicely handles on https://scripts.spaicely.com, one address for every site, and answers with the HTML to put in a page:
<form action="https://scripts.spaicely.com/f/k3v9q2x7m1c8b4n6r0td" method="post"> <input type="text" name="_hp" tabindex="-1" autocomplete="off" aria-hidden="true" style="position:absolute;left:-9999px"> <label>Name <input name="name" required></label> <label>Email <input type="email" name="email" required></label> <label>Message <textarea name="message" rows="6" required></textarea></label> <button type="submit">Send</button> </form>
A contact form mails every message to one recipient, by default the person who set it up; any other address confirms once by mail before it gets anything. The mail comes from no-reply@spaicely.com with the name of the site as sender, and replying goes straight to the visitor. A signup form keeps a list with double opt-in: the visitor confirms by mail, and scripts.subscribers returns the confirmed addresses with an unsubscribe link for each.
Style the form with the site's own CSS and add fields as you like; every field whose name does not start with _ ends up in the mail. The empty _hp field catches bots. A form only accepts posts from the site's own connected domain. To stay on the page, post it with fetch and Accept: application/json; the answer is then {"ok": true} or an error code, see the HTTP API. Sessions and a shopping cart need no server: keep them in the browser with localStorage.
MCP tools
Account and approvals
account.whoami
Returns the person and account you act for, your scopes, and per site your role and what you may do. Call this first.
approvals.status
A call of a kind of action the person set to ask (see ai_permissions in account.whoami) does not run at once: it answers with approval_required, an approval id and a url for the person. Do not repeat that call. Once the person says they decided, pass the approval id here: status is pending, approved, rejected or expired, and for an approved call result is the answer the call gave, exactly as if it had run at once. Without an id: the approvals of this account that still wait. Needs scope account:read.
| Parameter | Type | Required | Description |
|---|---|---|---|
approval | string | no |
account.members
Lists the people on the account you act for, with their role (owner or member) and the open invitations. Everyone on an account works on all its sites; owners also handle payment, domains and people. For owners: invite mails a link, valid for 7 days, to an address (role member or owner); set_role with user_id and role changes a role; remove with user_id takes someone off; cancel_invitation with an e-mail address withdraws an invitation. People on a single site instead of the whole account: access.update. Ask the person first. Needs scope access:manage.
| Parameter | Type | Required | Description |
|---|---|---|---|
invite | string | no | E-mail address to invite |
role | string (member, owner) | no | For invite and set_role; default member |
set_role | boolean | no | |
remove | boolean | no | |
user_id | integer | no | For set_role and remove, from the list |
cancel_invitation | string | no | E-mail address of an open invitation |
account.billing
Shows the billing details of the account you act for: whether it is a business, VAT number, address and country, and changes them with the fields you pass. They go on every invoice and decide the VAT: a business in another EU country with a VAT number that VIES confirms pays no Dutch VAT. A VAT number is checked with VIES on saving. Hosting and domains cannot be bought without them. Only owners. Needs scope billing:write.
| Parameter | Type | Required | Description |
|---|---|---|---|
business | boolean | no | |
vat_number | string | no | |
billing_street | string | no | |
billing_postal_code | string | no | |
billing_city | string | no | |
billing_country_code | string | no | Two letters, such as NL |
account.invoices
Lists Spaicely's invoices to the account you act for, newest first (at most 50): number, date, amounts in euro cents and a url where the person reads and prints it. A credit invoice has a negative total. Only owners. Needs scope billing:write.
Domains and DNS
domains.search
Checks whether domain names are free to register. Pass full domains (bakkerijjansen.nl) and/or bare names (bakkerijjansen); a bare name is tried with every extension in extensions, or by default with the ones people ask for most: nl, com, eu, be, de, net, org, shop, online, site, store, tech, app, dev, io, ai, co.uk, uk, fr, at, ch, it, es, nu, se, co, me, info, biz, xyz, cloud. We sell 718 extensions in all (.amsterdam, .restaurant, .photography and so on); pass them in extensions when they fit the name. At most 50 domains per call. The first check is quick and asks DNS only: taken means the domain has nameservers (certain); likely_available means it has none, so it is probably free (reason dns_only, or no_rdap where the registry has no RDAP and verifying adds nothing). Once the person has favourites, call again with verify: true for the dns_only ones (at most 10 domains): that asks our registrar (or else the registry over RDAP) and turns them into available or taken; otherwise reason says why (rdap_rate_limited, rdap_unavailable). A premium name (premium: true) costs what its registry asks: price is our price for it in euro cents before VAT, often far above the usual price, so tell the person. unknown means no answer at all. Each free domain has a register_url: a page where the person signs in and registers it in their own name. Registration itself checks again. Each taken domain has a transfer_url: if the domain is the person's own, they move it to Spaicely there.
| Parameter | Type | Required | Description |
|---|---|---|---|
names | array of string | yes | Domains or bare names, such as bakkerijjansen.nl or bakkerijjansen |
extensions | array of string | no | Extensions to try bare names with, such as nl, com, eu. Default: the ones people ask for most. |
verify | boolean | no | Deep check for a shortlist: also ask the registries. At most 10 domains. |
domains.register
Registers a domain in the name of the customer account you act for; that account becomes the holder. Call it first without confirmation: it checks the name and the holder details and returns a summary with the price and a confirmation. Show the summary to the person and ask whether to go ahead; only then call again with the same name and that confirmation. That call returns a payment_url: give it to the person, who pays there; the domain is registered once the payment is in. Holder details are needed once per account (pass contact); later domains reuse them. A consumer (an account that is not a business) must first agree to have the order carried out at once and to lose the 14-day right of withdrawal once it is done; ask them, and pass start_now: true with the second call only when they said yes themselves. Without it a consumer cannot order. Automatic renewal is on by default and charges the card or direct debit used for this payment; see domains.auto_renew. Needs scope domains:buy.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | yes | The full domain, such as bakkerijjansen.nl |
contact | object | no | The holder as the registry records it. Leave out to reuse the last one. |
confirmation | string | no | From the first call, after the person agreed. |
start_now | boolean | no | Required for consumers, with the second call: carry out the order at once and give up the 14 days to withdraw. Only when the person said so themselves. |
domains.propose
For a partner working on a customer's site (as admin, maintainer or designer): proposes a domain for that site. The customer becomes the holder and gets a mail with a link, valid for 14 days, to check the holder details and agree; nothing is ordered before that. payer is customer (the customer pays when agreeing) or partner (an owner of the partner account pays afterwards, on the page in proposal_url). contact fills in the holder details for the customer to check; leave it out to let the customer fill them in. Ask the person first. Needs scope domains:buy.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
name | string | yes | The full domain, such as bakkerijjansen.nl |
payer | string (customer, partner) | no | |
contact | object | no | The holder (the customer) as the registry records it; optional. |
domains.transfer
Moves a domain that is registered elsewhere to Spaicely, in the name of the customer account you act for; that account is the holder. The person gets the transfer code (also called auth code, EPP code or token) from their current registrar and must unlock the domain there. Do not ask for the code in the conversation: call with only name. That checks whether the domain can move and returns a url where the person signs in, enters the code and the holder details and confirms; give them that link. domains.status shows the move afterwards. Only when the person pastes the code into the conversation themselves: call with name and transfer_code and without confirmation for a summary and a confirmation, ask the person, then call again with the same name, the same transfer_code and that confirmation; that call returns a payment_url for the person, and the move starts once the payment is in. A consumer must agree to start_now first (see domains.register). Automatic renewal is on by default (domains.auto_renew). Needs scope domains:buy.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | yes | The full domain, such as bakkerijjansen.nl |
transfer_code | string | no | Only when the person gave it in the conversation themselves |
contact | object | no | The holder as the registry records it. Leave out to reuse the last one. |
confirmation | string | no | From the first call, after the person agreed. |
start_now | boolean | no | Required for consumers, with the second call: carry out the order at once and give up the 14 days to withdraw. Only when the person said so themselves. |
domains.auto_renew
Shows whether a domain held by the account you act for renews automatically, and switches it on or off with on. Automatic renewal is on by default: Spaicely charges the card or SEPA direct debit the domain was paid with, 14 days before the expiry date, and e-mails the amount beforehand. When no payment method is saved, the answer has a setup_url: give it to the person, who saves a card or mandate at Stripe there. Leave on out to only look. A consumer (an account that is not a business) who switches it on must agree that each renewal is carried out at once, without the 14-day right of withdrawal; pass start_now: true only when they said so themselves. Only owners of the holder account can change it. Needs scope domains:buy.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain | string | yes | The full domain, such as bakkerijjansen.nl |
on | boolean | no | true to switch on, false to switch off; leave out to only look. |
start_now | boolean | no | For consumers switching it on: renewals start at once, without the right of withdrawal. |
domains.renew
Gives a link where the person pays for the renewal of a domain registered through Spaicely now, instead of waiting for automatic renewal (domains.auto_renew); the domain renews after payment, for the period in the answer. A consumer (an account that is not a business) must agree that the renewal is carried out at once, without the 14-day right of withdrawal; pass start_now: true only when they said so themselves. Only owners of the holding account. Needs scope domains:buy.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain | string | yes | |
start_now | boolean | no |
domains.holder
Shows the holder of a domain registered through Spaicely, as the registry knows it, and changes it with the fields you pass. Address, phone and e-mail go to the registry at once, for every domain on the same holder record (shared_with lists them). Another name or organisation is a change of holder: Spaicely staff handle it, which can take a few working days. The holder is the legal owner of the domain: change it only with the person's own details or what they said. Only owners of the holding account. Needs scope domains:buy.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain | string | yes | |
name | string | no | |
organization | string | no | |
email | string | no | |
phone | string | no | |
street | string | no | |
postal_code | string | no | |
city | string | no | |
country_code | string (NL, BE, DE, FR, AT, CH, LU, GB, IE, IT, ES, PT, DK, SE, NO, FI, PL, CZ, US) | no |
domains.lock
Switches the transfer lock of a domain registered through Spaicely on or off. With the lock on, nobody can move the domain to another provider, even with the transfer code. Switch it off only when the person moves the domain away; domains.status shows the lock. Only owners of the holding account. Needs scope domains:buy.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain | string | yes | |
locked | boolean | yes |
domains.nameservers
Sets the nameservers of a domain registered through Spaicely. ours: true puts back Spaicely's own (needed for a site and for mail forwarding). Other nameservers (2 to 13) only for a domain that is not on a site, for instance when the person keeps their DNS elsewhere; Spaicely's DNS then no longer applies. Ask the person first. Only owners of the holding account. Needs scope domains:buy.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain | string | yes | |
ours | boolean | no | |
nameservers | array of string | no | Host names, such as ns1.example.net |
domains.transfer_code
Asks for the transfer code (auth code) of a domain registered through Spaicely, to move it to another provider. The code goes by mail to the person and the holder, not to you; every new request makes the earlier code useless, so ask at most a few times an hour. The transfer lock must be off at the moment of the move (domains.lock). Ask the person first. Only owners of the holding account. Needs scope domains:buy.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain | string | yes |
sites.domains.connect
Connects a domain to a site, so the site answers on https://<domain> and www sends people on to it. Spaicely runs the DNS of every domain it hosts. Call it with the domain itself (no www): Spaicely first looks up the records the domain has now (mail, verifications); call again after about half a minute to get them in records_to_confirm. Show those to the person: kept records come along so mail keeps working, records with reason site are replaced by the website, reason certificate means a CAA record that would block the certificate. Then call with confirm: true (and skip with the index of records to leave out). After that the person sets the nameservers at their registrar to the two in the answer; domains we registered already have them. We check every five minutes, then get the certificate and mail the person. domains.status shows the state at any time. Needs scope dns:write and a role that may change DNS.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
domain | string | yes | Such as bakkerijjansen.nl |
confirm | boolean | no | After the person agreed to the records in records_to_confirm. |
skip | array of integer | no | Indexes of found records to leave out. |
sites.domains.disconnect
The site stops answering on the domain and on its www. Other records, such as mail, stay in Spaicely's DNS as long as the nameservers point at us. Ask the person first. Needs scope dns:write and a role that may change DNS.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain | string | yes |
domains.status
Shows whether a domain is connected to its site, what is still needed (records to confirm, nameservers at the registrar, DNSSEC to switch off, the certificate) and the DNS records Spaicely serves for it. current_nameservers is what the registry says now. For a domain of the account that is on no site, it shows the registry side instead: expiry, automatic renewal, transfer lock, nameservers, and the DNS records when Spaicely's DNS serves it. Needs scope domains:read.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain | string | yes |
domains.dns.update
Adds or deletes DNS records of a domain that is connected to a site, or of a domain of the account on no site (one registered through Spaicely then gets Spaicely DNS, which only answers once its nameservers are ours), such as MX and TXT for mail or a CNAME for a service on a subdomain. Types: A, AAAA, CNAME, TXT, MX, SRV, CAA. name is relative to the domain (empty or @ for the domain itself). Where the domain itself and www point stays with the site. delete takes record ids from domains.status. Ask the person first. Needs scope dns:write and a role that may change DNS.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain | string | yes | |
add | array of object | no | |
delete | array of integer | no |
People and handovers
access.list
Lists the owner account, the paying account and everyone with a role on a site: people (name, email) and partner accounts, with role, whether they may publish and their file rules (ADR 0006: actions on glob paths, optionally limited to marked regions). Also open handovers. Roles: admin, maintainer, designer, editor, viewer. Needs scope sites:read.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes |
access.update
Gives a person a role on a site, changes it, or with role none takes them off. The person needs a Spaicely account with that email address; otherwise ask them to make one at app.spaicely.com. Roles: admin (everything but owner matters), maintainer, designer, editor, viewer. can_publish lets a maintainer or editor publish. rules limits a designer or editor to files, as in ADR 0006: [{"actions": ["create","update","delete"], "paths": ["blog/**"], "regions": ["news"]}]; regions are blocks marked <!-- spaicely:edit news --> in HTML. Ask the person first. Only the owner may do this. Needs scope access:manage.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
email | string | yes | |
role | string (admin, maintainer, designer, editor, viewer, none) | yes | |
can_publish | boolean | no | |
rules | array of object | no |
sites.handover
For a partner account that owns a site: mails the customer a link, valid for 14 days, to take the site onto their own account. role is what the partner keeps afterwards (the customer may narrow it or drop it; omit it to stop working on the site); payer is customer or partner. The site moves only when the customer accepts. Ask the person first. Needs scope partner:manage.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
email | string | yes | |
role | string (admin, maintainer, designer, editor, viewer) | no | |
payer | string (customer, partner) | no |
sites.handover.revoke
Takes back a handover sent with sites.handover that the customer has not accepted yet: the link in their mail stops working and the site stays with the partner. Needs scope partner:manage.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes |
Sites and files
sites.create
Makes a new, empty site on the account you act for, owned and paid for by that account. Then sites.deploy and sites.publish put the first version on it, and sites.domains.connect puts it on the person's domain. Ask the person for the name first. Needs scope content:write and the role owner or member of a customer account. A site needs a hosting subscription: without one it is removed after 14 days; tell the person and give them the subscription_url from the answer. Once paid and published, the site also answers on its address (<label>.spaicely.site, in the answer), to share before a domain is connected; search engines are told to skip it.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | yes | Name of the site as the person calls it |
sites.update
Gives a site another name, at most 80 characters. The name is only for the person and the people they work with; visitors never see it. Needs scope access:manage and the role owner or admin.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
name | string | yes |
sites.delete
Deletes a site for good: it goes offline at once, and every version, form and the shop disappear. The hosting stops without a refund for the current month; a consumer who can still withdraw should do that first (sites.subscription shows withdrawable). Connected domains stay the person's, with their DNS and mail. This cannot be undone: tell the person exactly what goes, and pass confirm_name only as the site name they typed or said themselves. Only owners of the site. Needs scope access:manage.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
confirm_name | string | yes | The name of the site, as the person confirmed it |
sites.subscription
Shows the hosting plan of a site: plan, status, the date it renews or ends, the paying account, and whether a consumer can still withdraw. Pass plan (site, pro or shop) to start hosting when the site has none: the answer has a checkout_url where the person pays at Stripe; give it to them. On a site that has a plan, plan switches to it straight away; Stripe settles the rest of the month on the next invoice. Card, invoices and cancelling are at portal_url. A consumer (an account that is not a business) who starts hosting must agree that it starts at once, which limits the right of withdrawal to the unused days; pass start_now: true only when they said so themselves. Ask the person before you start or switch a plan. Only owners of the paying account. Needs scope billing:write.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
plan | string (site, pro, shop) | no | Start this plan, or switch to it; leave out to only look. Site EUR 9, Pro EUR 19, Shop EUR 39 a month excl. VAT (GBP 8/17/34 in the United Kingdom, USD 11/22/45 outside Europe). |
start_now | boolean | no | For consumers starting a plan: hosting starts at once. |
sites.versions
Lists the versions (deployments) of a site, newest first: number, status (draft, ready, publishing, live, failed), files and who made it. Needs scope sites:read.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes |
sites.deploy
Starts a new version of a site. Pass every file of the finished site (not only the changed ones) with its path from the site root, its SHA-256 in hex and its size in bytes. For an image (or any file) that is already on the web, pass source_url instead of sha256 and size: we fetch it (https, at most 20 per call, 25 MB each), so you need not send it as base64; use only images the person may use. The answer lists missing: send exactly those with sites.files.put. Has the site as a folder on disk? Use sites.upload_url instead. A version goes live only with sites.publish. Needs scope content:write; your role decides which paths you may change.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
files | array of object | yes |
sites.files.put
Sends the contents of files that sites.deploy listed as missing, at most 50 files and 2 MB per call. Text files as encoding utf8, images and other binary files as base64. The contents must match the SHA-256 you listed. The answer says which files are still missing. Needs scope content:write.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
deployment | integer | yes | The number sites.deploy returned |
files | array of object | yes |
sites.upload_url
Returns a one-time URL, valid 10 minutes, that takes the finished site as one tar.gz archive with index.html at its root. Upload with tar -czf site.tar.gz -C <folder> . && curl -T site.tar.gz <url>; the answer to that request is the new version. Then call sites.publish. Needs scope content:write.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes |
sites.publish
Puts a ready version live; visitors switch over at once, never to a mix of two versions. Pass deployment, or rollback: true to bring back the version that was live before. It takes up to half a minute; sites.versions shows when it is live, with a report whose hints say what the site still lacks (sitemap, social tags, alt texts...), each with a fix. If someone published after your version was started, your changes are merged onto theirs and that merge goes live; a conflict comes back per path. Ask the person before you publish. Needs scope sites:publish and a role that may publish.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
deployment | integer | no | |
rollback | boolean | no |
sites.review.request
For a person who may change files but not publish (sites.publish refuses them): asks the people who may publish to approve a ready version. They get a mail and put it live from the dashboard or with sites.publish, or turn it down with a note; sites.versions shows the state under review. Pass a short note on what changed. Only on plan Pro and Winkel; on plan Site the person asks someone who may publish. Needs scope content:write.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
deployment | integer | yes | |
note | string | no |
sites.review.reject
Sends back a version someone asked approval for with sites.review.request, with a note that tells them what to change; they get it by mail. To approve, publish the version with sites.publish. Ask the person first. Needs scope sites:publish and a role that may publish.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
deployment | integer | yes | |
note | string | no | What should change, for whoever asked |
sites.files.list
Lists the files of the live version of a site, or of deployment: path, SHA-256 and size. HTML pages also list their regions, the blocks marked <!-- spaicely:edit name --> or <!-- spaicely:append name --> that a role can be limited to. Pass prefix to list one folder. Fetch contents with sites.files.get. Needs scope sites:read.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
deployment | integer | no | |
prefix | string | no | Such as blog/ |
sites.files.get
Returns the contents of up to 20 files (1 MB together) of the live version of a site, or of deployment, exactly as they were uploaded. Text files come back as utf8, others as base64. Read a page before you change it, so you keep what others made. Needs scope sites:read.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
deployment | integer | no | |
paths | array of string | yes |
sites.region.update
Makes a new version that differs from the live one in one region of one page: the block between <!-- spaicely:edit name --> and <!-- /spaicely:edit --> (or spaicely:append). mode replace (the default) swaps the contents, prepend or append adds HTML before or after them; an append region only takes prepend or append. index picks the region when a page has several with the same name (0 is the first). sites.files.list shows the regions. Then ask the person and call sites.publish. Needs scope content:write.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
path | string | yes | Such as index.html |
region | string | yes | |
content | string | yes | HTML |
mode | string (replace, prepend, append) | no | |
index | integer | no |
sites.stats
How much a published site is visited, per day over the last 7, 30 or 90 days (default 30), counted by the CDN without cookies or scripts on the site, in total and in per_day: requests (every file served: pages, images, styles; not people), bytes sent, not_found (addresses that do not exist, a hint at broken links) and the CDN regions that served them, close to the visitors. Needs scope sites:read.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
days | integer (7, 30, 90) | no |
Mail forwarding
mail.forward.status
Whether mail to a domain connected to a site is forwarded, its addresses (info@, or * for every address) and where each goes, with the state of each destination (pending until the address clicked the confirmation link we mailed it), and the mail held back in the last 30 days as possible spam (subject, sender, reason, id for mail.forward.release). Every address costs EUR 1.50 (GBP 1.30, USD 1.75) a month excl. VAT on the site's plan; Site allows 1, Pro 5, Shop 10. Needs scope mail:write and the owner or admin role.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain | string | yes |
mail.forward.enable
Lets Spaicely receive the mail of a domain connected to a paid site, so its addresses can be forwarded to mailboxes the person already has (mail.forward.add). Sets the records in Spaicely's DNS: MX to relay-mx.spaicely.net, a verification TXT, DKIM, the SPF include and DMARC if missing. When the domain already receives mail elsewhere (an MX of its own), that stops: the call refuses unless replace_existing_mail is true, so ask the person first. Spam and doubtful mail is held back, not forwarded. Needs scope mail:write and the owner or admin role.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain | string | yes | |
replace_existing_mail | boolean | no |
mail.forward.disable
Stops forwarding for a domain: its addresses go, held-back mail is deleted, the MX and the other forwarding records leave Spaicely's DNS and the addresses are no longer billed. Mail to the domain is refused until it gets an MX elsewhere. Ask the person first. Needs scope mail:write and the owner or admin role.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain | string | yes |
mail.forward.add
Forwards an address of the domain (address: the part before the @, such as info, or * for every address without one of its own) to up to 5 mailboxes. Each new destination gets a mail from Spaicely with a link; nothing is forwarded there until it is clicked. Called again for an address that exists, it adds destinations. Each address costs EUR 1.50 (GBP 1.30, USD 1.75) a month excl. VAT on the site's plan, prorated; Site allows 1, Pro 5, Shop 10. Ask the person first. Needs mail.forward.enable before, scope mail:write and the owner or admin role.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain | string | yes | |
address | string | yes | |
destinations | array of string | yes |
mail.forward.remove
Removes a forwarded address of the domain (address: info, or *), or with destination only that one mailbox. Mail to a removed address is refused, unless * forwards it. The address is no longer billed. Ask the person first. Needs scope mail:write and the owner or admin role.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain | string | yes | |
address | string | yes | |
destination | string | no |
mail.forward.release
Sends a message that was held back as possible spam on to its destinations after all (id from mail.forward.status). Only when the person recognises it as real mail; never for mail held as spam or scam unless they insist. Needs scope mail:write and the owner or admin role.
| Parameter | Type | Required | Description |
|---|---|---|---|
domain | string | yes | |
message_id | string | yes |
Forms
scripts.list
Lists the forms (scripts) of a site: form_id, kind (contact, signup, download or coupon), status (active, waiting_for_recipient, waiting_for_file, waiting_for_codes, disabled), the endpoint on scripts.spaicely.com, counts (for coupon: codes left and given out) and the HTML to put in a page. Needs scope sites:read.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes |
scripts.enable
Adds a form to a site, handled by Spaicely on https://scripts.spaicely.com. kind contact mails every message to recipient (by default the person's own address); another address must confirm once by mail. kind signup keeps a list with double opt-in: the visitor confirms by mail, scripts.subscribers gives the list. kind download mails what a visitor fills in to recipient and gives the visitor a link to file_path, a file you deploy under _private/ (never on the CDN, so the form is the only way to it); see delivery. kind coupon does the same with a discount code: coupon_code for one code for everyone, coupon_codes for codes that each go out once, or dated_codes for codes with a window (a shared code per week, or unique codes that expire). With price_cents a download or coupon form is paid: the visitor pays on Stripe to the site owner and gets the link or code by mail. kind donation takes donations for the site owner on every plan: amount buttons and a free amount, paid on Stripe to the owner's own account (2% commission), a thank-you mail to the donor and a mail per donation to recipient; donors may show their name and message. progress_url in the answer gives the total, the number of donors and the names shown, as JSON open to any origin, for a progress bar towards goal_cents. Keep what you raise: no money goes back when a goal is not reached. Not for investments or loans. The answer has html: put that form in a page as it is and style it with the site's CSS; you may add fields (any name, not starting with _), the field email becomes the reply address. The form only works on the site's connected domain. Needs scope scripts:write and the role owner, admin or maintainer.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
kind | string (contact, signup, download, coupon, donation) | yes | |
name | string | yes | What the person calls the form, such as Contact |
recipient | string | no | contact, download and coupon: the address that gets the messages |
locale | string (nl, en, de, fr) | no | Language of the mails and our pages |
subject | string | no | contact: subject of the mail |
success_url | string | no | Page on the site's own domain to show after sending |
confirmed_url | string | no | signup: page on the site's own domain after confirming |
file_path | string | no | download: the file in the site that visitors get, under _private/ (such as _private/guide.pdf); deploy it like any other file, it never goes to the CDN |
delivery | string (mail, direct) | no | download and coupon: mail (default) sends the link or code to the address filled in, which shows it is real; direct starts the download or shows the code right after sending (coupon: only codes everyone gets, coupon_code or shared codes; a paid form always mails) |
keep_leads | boolean | no | contact, download and coupon (free forms): also keep what visitors send, besides the mail, so the owner sees and exports it in the dashboard (scripts.leads). Off by default; kept leads go after a year |
coupon_code | string | no | coupon: one code every visitor gets, such as WELCOME10 |
coupon_codes | array of string | no | coupon: codes that each go out once, added to the list (letters, digits, - and _, at most 40 characters); the recipient hears when 10 are left |
dated_codes | array of object | no | coupon: codes with a window, added to the list. shared true: everyone who asks during the window gets this code (a code per week, such as WEEK45 from 2026-11-03 to 2026-11-09); otherwise each goes out once and the mail says until when it is valid. Dates without a time are whole days in Dutch time. A shared code valid now goes before the unique codes, those before coupon_code |
clear_coupons | boolean | no | coupon: drop the codes not given out yet (and the shared ones) before adding |
price_cents | integer | no | download and coupon: price in cents including VAT (995 = EUR 9.95, at least 50); 0 makes the form free again. A paid form posts to our checkout: the visitor pays on Stripe, to the site owner's own Stripe account as in the web shop (plan Shop, 2% commission), and gets the link or code by mail after paying. The form asks the visitor to agree to delivery right away, which ends the right of withdrawal |
donation_amounts | array of integer | no | donation: the amount buttons in cents (1000 = EUR 10); the visitor may also type an amount. Empty gives 10, 25 and 50 euros |
donation_min_cents | integer | no | donation: lowest amount in cents (default 100, at least 50; 0 = default) |
donation_max_cents | integer | no | donation: highest amount in cents (default and at most 1000000; 0 = default) |
goal_cents | integer | no | donation: the goal in cents for a progress bar; 0 removes it |
ends_at | string | no | donation: last day (2026-12-31, whole day in Dutch time) or ISO 8601 time; after it the form takes no more donations. Empty removes the end |
scripts.configure
Changes a form made with scripts.enable: only the settings you pass. enabled false switches it off (visitors then get an error), true switches it on again. A new recipient must confirm by mail first. For a coupon form, coupon_codes and dated_codes add codes to its list. price_cents sets or (with 0) removes a price; a paid form gets a new endpoint, so put the new html in the page. Needs scope scripts:write and the role owner, admin or maintainer.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
form_id | string | yes | |
enabled | boolean | no | |
name | string | no | What the person calls the form, such as Contact |
recipient | string | no | contact, download and coupon: the address that gets the messages |
locale | string (nl, en, de, fr) | no | Language of the mails and our pages |
subject | string | no | contact: subject of the mail |
success_url | string | no | Page on the site's own domain to show after sending |
confirmed_url | string | no | signup: page on the site's own domain after confirming |
file_path | string | no | download: the file in the site that visitors get, under _private/ (such as _private/guide.pdf); deploy it like any other file, it never goes to the CDN |
delivery | string (mail, direct) | no | download and coupon: mail (default) sends the link or code to the address filled in, which shows it is real; direct starts the download or shows the code right after sending (coupon: only codes everyone gets, coupon_code or shared codes; a paid form always mails) |
keep_leads | boolean | no | contact, download and coupon (free forms): also keep what visitors send, besides the mail, so the owner sees and exports it in the dashboard (scripts.leads). Off by default; kept leads go after a year |
coupon_code | string | no | coupon: one code every visitor gets, such as WELCOME10 |
coupon_codes | array of string | no | coupon: codes that each go out once, added to the list (letters, digits, - and _, at most 40 characters); the recipient hears when 10 are left |
dated_codes | array of object | no | coupon: codes with a window, added to the list. shared true: everyone who asks during the window gets this code (a code per week, such as WEEK45 from 2026-11-03 to 2026-11-09); otherwise each goes out once and the mail says until when it is valid. Dates without a time are whole days in Dutch time. A shared code valid now goes before the unique codes, those before coupon_code |
clear_coupons | boolean | no | coupon: drop the codes not given out yet (and the shared ones) before adding |
price_cents | integer | no | download and coupon: price in cents including VAT (995 = EUR 9.95, at least 50); 0 makes the form free again. A paid form posts to our checkout: the visitor pays on Stripe, to the site owner's own Stripe account as in the web shop (plan Shop, 2% commission), and gets the link or code by mail after paying. The form asks the visitor to agree to delivery right away, which ends the right of withdrawal |
donation_amounts | array of integer | no | donation: the amount buttons in cents (1000 = EUR 10); the visitor may also type an amount. Empty gives 10, 25 and 50 euros |
donation_min_cents | integer | no | donation: lowest amount in cents (default 100, at least 50; 0 = default) |
donation_max_cents | integer | no | donation: highest amount in cents (default and at most 1000000; 0 = default) |
goal_cents | integer | no | donation: the goal in cents for a progress bar; 0 removes it |
ends_at | string | no | donation: last day (2026-12-31, whole day in Dutch time) or ISO 8601 time; after it the form takes no more donations. Empty removes the end |
scripts.subscribers
Lists who signed up with a signup form: email, name, when they signed up and confirmed, and an unsubscribe_url to put in every mail sent to them. Only confirmed people who did not unsubscribe, unless all is true. Needs scope scripts:write and the role owner, admin or maintainer.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
form_id | string | yes | |
all | boolean | no |
scripts.subscribers.update
For a signup form: action unsubscribe keeps the address on the list as unsubscribed, so it gets no more mail; remove erases everything about them, for instance when they ask to be forgotten. Ask the person first. Needs scope scripts:write and the role owner, admin or maintainer.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
form_id | string | yes | |
email | string | yes | |
action | string (unsubscribe, remove) | yes |
scripts.leads
Lists what visitors sent through a contact, download or coupon form that keeps leads (keep_leads true in scripts.configure): when, the fields in the order of the form, the page and the code a coupon form gave. Newest first, at most 500; before (an id) pages back. Leads go after a year. Needs scope scripts:write and the role owner, admin or maintainer.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
form_id | string | yes | |
before | integer | no | Only leads older than this id |
scripts.leads.remove
Removes leads a form kept (scripts.leads): the ones in ids, or every lead with all: true, for instance when a visitor asks to be forgotten. The mails already sent stay where they are. This cannot be undone: ask the person first. Needs scope scripts:write and the role owner, admin or maintainer.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
form_id | string | yes | |
ids | array of integer | no | Lead ids from scripts.leads |
all | boolean | no |
scripts.remove
Removes a form made with scripts.enable, with everything it kept: signups, leads and codes. Visitors who still post to it get an error, so take the form out of the pages first. To stop it for a while, use scripts.configure with enabled false instead. This cannot be undone: ask the person first. Needs scope scripts:write and the role owner, admin or maintainer.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
form_id | string | yes |
Web shop
shop.setup
Sets up checkout for a web shop on a site with the Shop plan. Payments go through Stripe Checkout on the site owner's own Stripe account, so the owner is the seller; Spaicely keeps a commission (commission_percent). Products and prices come from a JSON file in the live version of the site (products_path, default data/products.json): {"products":[{"id":"mug-blue","name":"Blue mug","price":1495,"description":"...","image":"/img/mug.jpg"}],"shipping":[{"id":"nl","name":"Shipping in the Netherlands","price":495,"countries":["NL"]}]} with prices in cents including VAT; shipping is optional. Keep the cart in the browser (localStorage) and POST {items:[{id,quantity}], shipping} as JSON to endpoint with fetch, then go to the url in the answer; or use the form in html as a buy button. Only the site's connected domain and its address may post. status says what is missing: waiting_for_plan, waiting_for_billing_details, waiting_for_stripe (give the person connect_url, where they make a Stripe account or link the one they have; stripe_needs says what Stripe still asks of them), waiting_for_products (see problems), or active. Call again after publishing the products file. Needs scope scripts:write and the role owner, admin or maintainer.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
products_path | string | no | Path of the products file in the site, default data/products.json |
success_url | string | no | https page on the site's own domain after paying |
cancel_url | string | no | https page on the site's own domain when the buyer goes back |
locale | string (nl, en, de, fr) | no | Language of Stripe Checkout and the order mails |
notify_email | string | no | Where order mails go; default the owners of the account |
enabled | boolean | no |
shop.orders
Lists the latest paid orders (at most 50) of the shop of a site: when, buyer name and email, shipping address, lines, total in cents and Spaicely's commission. Refunds and payouts are in the owner's own Stripe dashboard. Needs scope sites:read; the buyer's details only for the role owner, admin or maintainer.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes |
HTTP API
Most of Spaicely is reached over MCP, but a few things are plain HTTP: domain search without an account, the forms on customer sites, uploading a site as one archive, and the OAuth endpoints every MCP client uses. Some examples first; the full list follows.
Is a domain free?
No account and no key. Names go in the path, comma-separated; extensions are optional.
curl https://app.spaicely.com/api/domains/search/bakkerijjansen/nl,com
{
"results": [
{
"domain": "bakkerijjansen.nl",
"extension": "nl",
"status": "likely_available",
"checked_via": "dns",
"reason": "dns_only",
"register_url": "https://app.spaicely.com/domains/new?name=bakkerijjansen.nl"
},
…
],
"verify_url": "https://app.spaicely.com/api/domains/verify/bakkerijjansen.nl,bakkerijjansen.com",
"statuses": { … },
"reasons": { … }
}
The quick check only asks DNS. For the names someone likes, follow verify_url: it asks the registry (at most 10 domains) and gives available or taken, and a price for premium names. /api/domains/extensions lists every extension with its price.
curl https://app.spaicely.com/api/domains/verify/bakkerijjansen.nl,bakkerijjansen.com curl https://app.spaicely.com/api/domains/extensions
From JavaScript in a browser (CORS is open):
const answer = await fetch("https://app.spaicely.com/api/domains/search/bakkerijjansen/nl,com").then((r) => r.json());
const free = answer.results.filter((r) => r.status !== "taken").map((r) => r.domain);
Sending a form from JavaScript
A form made with scripts.enable works as plain HTML. To stay on the page, send it with fetch and ask for JSON:
form.addEventListener("submit", async (event) => {
event.preventDefault();
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new URLSearchParams(new FormData(form)),
});
const answer = await response.json(); // {"ok": true} or {"ok": false, "error": "rate_limited"}
form.replaceWith(answer.ok ? "Thank you, we will get back to you." : "That did not work, please try again later.");
});
Only the site's own connected domain may post; from anywhere else, curl included, the answer is 403 origin_not_allowed.
Uploading a site in one go
sites.upload_url gives a one-time address, valid for ten minutes. Send the site as a tar.gz to it; the answer is the new version, which sites.publish then puts live.
tar -czf site.tar.gz -C _site . curl -T site.tar.gz "https://app.spaicely.com/uploads/<token from sites.upload_url>"
Calling MCP without a client library
The server is stateless, so one POST is one call. The public server needs no sign-in:
curl https://mcp.spaicely.com/mcp/public \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "domains.search", "arguments": {"names": ["bakkerijjansen"], "extensions": ["nl"]}}}'
The signed-in server at https://mcp.spaicely.com/mcp takes the same requests with Authorization: Bearer <access token>. Without one it answers 401 with a WWW-Authenticate header that points to the protected resource metadata, where an MCP client finds everything it needs to sign in:
- Read
/.well-known/oauth-protected-resource/mcpon mcp.spaicely.com, then/.well-known/oauth-authorization-serveron app.spaicely.com. - Register with
POST /oauth/register(dynamic client registration; public clients, no secret), or skip this step and pass the https URL of your client ID metadata document asclient_id. - Send the person to
/oauth/authorizewith PKCE S256 andresource=https://mcp.spaicely.com/mcp; they choose the account, sites and scopes. - Swap the code at
POST /oauth/token. An access token lasts an hour; the refresh token is replaced at every use and lapses after 30 days unused.
All endpoints
GET https://app.spaicely.com/api/domains/search/{names}/{extensions}
Quick check (DNS only) whether domains are free: comma-separated names or full domains, then optional extensions, at most 50 domains. The query form ?names=&extensions= works too. Answers status per domain (taken, likely_available with a reason, unknown, invalid, unsupported), a register_url or transfer_url, and a verify_url for the deep check. CORS open; 30 requests a minute and 300 an hour per IP address.
GET https://app.spaicely.com/api/domains/verify/{names}/{extensions}
Deep check of at most 10 domains: DNS, then our registrar (checked_via registrar) or else the registry over RDAP. Answers available or taken where one answers, otherwise likely_available with reason rdap_rate_limited, rdap_unavailable or no_rdap. A premium name has premium: true and price: our price in euro cents before VAT for the extension's term.
GET https://app.spaicely.com/api/domains/extensions
The extensions people ask for most (extensions, the default for a bare name), all 718 Spaicely sells (all), the most domains per request, and which extensions a deep check can settle (verifiable), and a price class per extension (price_classes: 0 cheap to 4 dear), and our prices (prices: per extension create, renew and transfer in euro cents before VAT, each for the whole term paid up front: months for create, renew_months and transfer_months, 12 for most and 24 for .ai; transfer_months null means a move adds no term, as for .nl, and then often costs nothing; add VAT where it applies), and euro_rates: euros per GBP and per USD, the rate at which an account in the United Kingdom (pounds) or outside Europe (dollars) pays those prices.
PUT https://app.spaicely.com/uploads/{token}
Upload a whole site as one tar or tar.gz (index.html at the root, plain files only, at most 200 MB) to the URL that sites.upload_url returned. It works once, for 10 minutes. POST works too. The answer is the new version; put it live with sites.publish.
POST https://scripts.spaicely.com/f/{form_id}
A contact form made with scripts.enable posts here: urlencoded from a plain HTML form, or JSON with fetch. Every field not starting with _ goes to the confirmed recipient by mail; the field email becomes the reply address; _hp is the honeypot and must stay empty. A plain form gets a redirect to success_url or a page of ours, a JSON request gets {"ok": true} or {"ok": false, "error": ...} (unknown_form, origin_not_allowed, form_inactive, invalid, rate_limited, failed). At most 30 fields, 64 KB; 10 posts per visitor per 10 minutes and 100 per form per hour.
POST https://scripts.spaicely.com/s/{form_id}
A signup form made with scripts.enable posts here, with email and optionally name. The visitor gets a mail to confirm (double opt-in); confirmed_url is where the link ends. The answer is the same whether the address was on the list or not. scripts.subscribers gives the list with an unsubscribe_url each.
POST https://scripts.spaicely.com/l/{form_id}
A download form made with scripts.enable posts here, with email and any other fields. Everything not starting with _ goes to the confirmed recipient by mail, as with a contact form, and the visitor gets a link to the form's file_path (under _private/, never on the CDN). delivery mail: the link goes to the address filled in, works 7 days, and a plain form gets success_url or a page of ours; delivery direct: a plain form is redirected to the link, a JSON request gets {"ok": true, "download_url": ...} to open, and the link works for an hour. A link works at most 5 times.
POST https://scripts.spaicely.com/k/{form_id}
A coupon form made with scripts.enable posts here, with email and any other fields. Everything not starting with _ goes to the confirmed recipient by mail, with the code the visitor got. The form has one code for everyone (coupon_code) and/or a list (coupon_codes, dated_codes): a shared code valid now goes first, then unique codes valid now (each once; the mail says until when it is valid), then coupon_code. delivery mail: the code goes to the address filled in, and a plain form gets success_url or a page of ours; delivery direct (only codes everyone gets): a JSON request gets {"ok": true, "code": ...}, a plain form a page with the code. The recipient gets a mail when 10 unique codes are left and when they ran out; without a code valid now the form answers as not active. A paid form does not post here but to /forms/{form_id}/checkout.
GET https://{site domain}/_download/{token}
The link a visitor gets from a download form, on the site's own domain; it never shows where the file lives. It shows a page that asks for the file by itself (so a mail scanner opening the link uses up nothing) and sends the file from the live version of the site, under its own name and type. Expired, used up, or the file no longer live: 404. A site cannot have files under _download/.
POST https://app.spaicely.com/forms/{form_id}/checkout
A download or coupon form with price_cents posts here instead of to the scripts service: a plain form (urlencoded) or fetch as JSON with email, start_now and any other fields. start_now must be 1: the visitor agrees to delivery right away, which ends the right of withdrawal for digital content. A form is redirected (303) to Stripe Checkout, JSON gets {"url": ...}. The visitor pays the site owner's Stripe account (2% commission, as the web shop); after paying the link or code goes to the address by mail and the details to the recipient. Errors: {"error": ..., "message": ...} with invalid_email, start_now_required, not_ready, origin (403) or too_many. A donation form posts here too, without start_now but with amount (one of its buttons, in euros) or amount_other (typed, "12,50" or "12.50"), optional name and message, and show_name 1 to show name and message on the site; an amount outside its range gives the error amount. The donor gets a thank-you mail, the recipient one per donation.
GET https://app.spaicely.com/forms/{form_id}/progress
What a donation form raised, for a progress bar on the site: {"raised_cents", "donors", "goal_cents", "ends_at", "open", "donations": [{"name", "message", "at"}]} with paid donations only and the last 20 names and messages donors chose to show. Cached for a minute; other forms give 404.
POST https://app.spaicely.com/shop/{shop_id}/checkout
A web shop made with shop.setup sends an order here, from a plain form (a buy button, urlencoded items[0][id], items[0][quantity], shipping) or with fetch as JSON {"items": [{"id": ..., "quantity": ...}], "shipping": ...}. Products and prices come from data/products.json in the live version, never from the request: at most 50 lines, 1 to 99 each; shipping is the id of a shipping option and needed when the file has any. A form is redirected (303) to Stripe Checkout, JSON gets {"url": ...} to open. Errors: {"error": ..., "message": ...} with invalid_items, invalid_shipping, shipping_required, not_ready, origin (403) or too_many (30 per visitor per 10 minutes). The buyer pays the shop owner's Stripe account; shop.orders lists the paid orders.
POST https://mcp.spaicely.com/mcp
MCP server for signed-in clients (Streamable HTTP, stateless; GET and DELETE answer 405). Without a token it answers 401 with WWW-Authenticate pointing to the protected resource metadata.
POST https://mcp.spaicely.com/mcp/public
MCP server without an account: domains.search and the prompt domain_brainstorm.
GET https://mcp.spaicely.com/.well-known/mcp/server-card.json
MCP server card: name, endpoints, authentication and tools. Also at /mcp/server-card.
GET https://mcp.spaicely.com/.well-known/oauth-protected-resource/mcp
Protected resource metadata (RFC 9728) for the MCP server: which authorization server and scopes.
GET https://app.spaicely.com/.well-known/oauth-authorization-server
Authorization server metadata (RFC 8414).
POST https://app.spaicely.com/oauth/register
Dynamic client registration (RFC 7591). Every client is public: no secret, PKCE S256 required. Instead, a client may pass the https URL of its client ID metadata document as client_id.
GET https://app.spaicely.com/oauth/authorize
Authorization code flow with PKCE S256. The person signs in and chooses the account, the sites and the scopes on the consent screen. The resource parameter, when given, must be https://mcp.spaicely.com/mcp.
POST https://app.spaicely.com/oauth/token
Exchanges the code, or a refresh token, for an access token (1 hour). Refresh tokens rotate on every use and expire after 30 days unused.
POST https://app.spaicely.com/oauth/revoke
Revokes an access or refresh token (RFC 7009).
Scopes
Rights a client asks for; on the consent screen they are all ticked and the person unticks what the AI may not do. A purchase always needs a second, confirmed call.
account:read | See your name, your account and the list of your sites |
sites:read | View your sites, versions and previews |
content:write | Change files on your sites, within your role |
sites:publish | Put changes live and roll them back |
scripts:write | Set up forms, the shopping cart and other scripts |
dns:write | Change the DNS records of your domains |
mail:write | Forward the mail of your domains (paid per address per month) |
domains:read | Search domain names and check their status |
domains:buy | Register, transfer and renew domain names (always with your confirmation) |
billing:write | Start or change a plan (always with your confirmation) |
access:manage | Invite people and change or remove their role |
partner:manage | Hand over sites and manage clients (for web designers) |
handover:accept | Accept a handover from a web designer |