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. Cannot compute a SHA-256 (no code execution)? Pass content instead, with encoding utf8 for text or base64 for images: at most 2 MB of content per call, and such a file is never missing. For unchanged files of a live site, copy sha256 and size from sites.files.list. 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. A product with sizes or colours gets "variants": [{"id":"shirt-m-blue","options":{"size":"M","colour":"blue"},"price":2700}] (price and image default to the product's); a cart then holds variant ids. Any other key (descriptions, categories, related products, properties) is the site's own and left alone. A large catalog goes in parts: "parts": ["data/products/shirts.json", ...], each {"products": [...]}, at most 20 MB a file; ids are unique over all of them. 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; the building block cart does all of this. GET items_url?ids=a,b gives names, prices and stock of up to 100 ids for a cart or product page. shop.stock keeps stock counts. 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), test_mode or active. Call again after publishing the products file. test_mode: true lets people try the whole shop before Stripe is set up or while it is: checkout then goes to a Spaicely test page where the tester picks paid, cancelled or failed; stock, mails (subject TEST) and thank-you pages work as for real, nothing is paid, and test orders go after 7 days. Switch it off to take real orders. cart_hold makes a cart hold stock from the moment something goes in (low: only ids running low; always) for cart_hold_minutes; then it goes back and the cart block takes it out of the cart. The cart block (v5) does this through hold_url; at most 5 of an id per cart. After each payment the buyer gets an order confirmation from Spaicely on behalf of the shop (confirm_to_buyer, default on): what was bought, the seller, and the pages in terms_url and returns_url. A seller must confirm an order with the terms and how to cancel, so make those pages and set them; notes lists no_terms_url or no_returns_url while they are missing. invoice_buyers: Stripe also makes an invoice in the seller's name, with the buyer's VAT number when given (Stripe charges for it). promotion_codes: checkout takes discount codes the owner makes in their own Stripe dashboard. 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 | |
test_mode | boolean | no | Test checkout without Stripe; nothing is paid |
cart_hold | string (off, low, always) | no | Hold stock from the moment it goes in a cart: off (only at checkout), low (ids at or below their alert_at, or 5) or always |
confirm_to_buyer | boolean | no | Mail the buyer an order confirmation, default true |
terms_url | string | no | https page on the site's own domain with the shop's terms |
returns_url | string | no | https page on the site's own domain on returns and the right to cancel |
invoice_buyers | boolean | no | Stripe sends the buyer an invoice in the seller's name |
promotion_codes | boolean | no | Checkout accepts the owner's Stripe promotion codes |
cart_hold_minutes | integer | no | How long a cart holds it, default 15 |
shop.orders
Lists the paid orders of the shop of a site, newest first, 50 at a time (before is the id of the oldest order of the previous answer), or one order by order_id. filter: to_ship (an address, not sent yet, not refunded in full), shipped, refunded or test. Each order has id (the order number the buyer got), paid_at, state (paid, to_ship, shipped, partly_refunded, refunded), total and refunded in cents, Spaicely's commission (less what went back on a refund), lines, and shipped_at, carrier, tracking_code and tracking_url once sent. Refunds are made by the owner in their own Stripe dashboard; Stripe tells Spaicely, the order shows it and the commission on it is returned. Mark an order as sent with shop.orders.ship. Needs scope sites:read; the buyer's name, email and address only for the role owner, admin or maintainer. Orders placed while the shop was in test mode have test: true.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
order_id | integer | no | |
filter | string (all, to_ship, shipped, refunded, test) | no | |
before | integer | no |
shop.orders.ship
Marks a paid order of the shop of a site as sent (order_id from shop.orders), with carrier, tracking_code and tracking_url (https) when there are any. The buyer gets a mail from Spaicely on behalf of the shop, in the shop's language, with the tracking details and the shop's returns page; notify: false leaves the mail out. Calling it again updates the details (and mails again unless notify is false); undo: true takes the mark off without a mail. An order refunded in full cannot be marked. Needs scope scripts:write and the role owner, admin or maintainer.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
order_id | integer | yes | |
carrier | string | no | |
tracking_code | string | no | |
tracking_url | string | no | |
notify | boolean | no | Mail the buyer, default true |
undo | boolean | no | Take the sent mark off |
shop.stock
Keeps stock for products and variants of the shop of a site. Spaicely keeps only the count; names, prices, variants and every other property stay in the products file of the site. An id without stock is never sold out. A checkout reserves what it sells for the 30 minutes Stripe Checkout stays open, a payment takes it off, an expired checkout gives it back; when too few are left the checkout answers out_of_stock. Without set or add it lists ids with stock: skus for those ids, otherwise all of them 500 at a time from after. set: [{sku, quantity, alert_at}] sets the count (quantity null stops keeping stock for that id); alert_at mails the shop when the count comes down to it, and there is always a mail when it is sold out. add: [{sku, add}] adds a delivery (or takes off with a negative number) on top of what is there, also while buyers are paying. At most 1000 rows per call. Each row answers with quantity, reserved (in checkout), held (in carts, see cart_hold in shop.setup) and available; unknown lists ids the live products file does not have (stock for them is kept for when they are published). On the site, GET items_url?ids=... from shop.setup gives what is left. Needs scope scripts:write and the role owner, admin or maintainer.
| Parameter | Type | Required | Description |
|---|---|---|---|
site_id | integer | yes | |
skus | array of string | no | Product or variant ids to read |
after | string | no | List ids after this one (the next_after of the previous answer) |
set | array of object | no | |
add | array of object | no |
Building blocks
blocks.list
Lists ready-made building blocks for a site on Spaicely: a cookie notice that holds back analytics until the visitor agrees, forms that send to Spaicely without leaving the page, videos and maps that load only after a click, a mobile menu, an FAQ with structured data, a language switch, opening hours that say whether the place is open now, a progress bar for a donation form and a cart for the shop. Prefer a block to writing the same thing yourself. blocks.get gives a block's files, the HTML snippet for the pages and notes. Needs no account.
blocks.get
Gives one building block from blocks.list: its files, the HTML snippet to put in the pages, and notes on using it. Copy the files into the site by passing deploy_files along in sites.deploy (each is { path, source_url }; Spaicely fetches them, so you do not retype the code), then put the snippet in every page that needs it, with its texts in the language of the page. Without version you get the newest; a version never changes. Needs no account.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | yes | The block's name from blocks.list, such as consent |
version | integer | no | A version; default the newest |
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. A client that only needs some files addspaths=blog/**,sitemap.xml; its tokens then change files only there. - 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": ...}. id is a product id, or a variant id for a product with variants. Products and prices come from data/products.json (and its parts) 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. Ids with stock (shop.stock) are reserved for the 30 minutes the Stripe Checkout stays open. A form is redirected (303) to Stripe Checkout, JSON gets {"url": ...} to open. Errors: {"error": ..., "message": ...} with invalid_items, invalid_shipping, shipping_required, out_of_stock (with "available": {id: count left}), 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. With test_mode on (shop.setup) the url is a Spaicely test page instead of Stripe, where the tester picks paid, cancelled or failed; nothing is paid. Send "cart" (the cart's token, see the hold endpoint) to check out what that cart holds.
GET https://app.spaicely.com/shop/{shop_id}/items?ids={id},{id}
Name, price (cents including VAT) and image of up to 100 product or variant ids as the live version of the site has them, and "stock" (how many are left) for ids the shop keeps stock for: {"currency": "eur", "items": {"mug-blue": {"name": ..., "price": 1495, "stock": 3}}, "missing": [...], "shipping": [...]}. Inactive and unknown ids are in missing; shipping lists the options of the products file, and "hold": {"mode": "low" or "always", "minutes": 15} when the shop holds stock in carts. For a cart or a product page, so a large catalog never has to be loaded whole. Not cached (stock changes with every sale; an unchanged answer is a 304 with ETag); 300 per visitor per minute.
POST https://app.spaicely.com/shop/{shop_id}/hold
When the shop holds stock in carts (cart_hold in shop.setup), a cart asks here to hold what it has of an id: JSON {"cart": token, "item": id, "quantity": n}, where token is 16 to 64 letters, digits, - or _ that the cart makes once and keeps; quantity 0 gives it back. Holds at most 5 of an id and 20 ids per cart, only what is available, and with cart_hold low only ids at or below their alert_at (or 5). Answers {"item", "held", "expires_in": seconds, "stock": available now}; held 0 means nothing is held. The time runs from the first hold, also when the quantity changes; then the stock goes back and that cart cannot hold the id again for 5 minutes. A checkout that sends the same cart takes over its holds. 120 per visitor per 10 minutes. The cart block (v5) does all of this.
GET wss://app.spaicely.com/cable
A websocket (Action Cable protocol, subprotocol actioncable-v1-json) that tells a shop's pages when stock changes: send {"command": "subscribe", "identifier": ...} where identifier is the JSON string of {"channel": "ShopStockChannel", "shop": "<shop_id>"}. Each message is {"stock": {id: count left}} (null when an id no longer keeps stock) after a checkout starts, ends, is paid or the owner changes stock, or {"refresh": true} after a large import: then ask the items endpoint again. Nothing about visitors is sent; there is nothing to send to it. Missed messages are not repeated, so ask the items endpoint after reconnecting. The cart block (v4 and later) does all of this.
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. A client that only needs some files asks for them with paths, comma-separated globs from the site root (paths=blog/**,sitemap.xml, at most 20): the consent screen names them, and every token from that grant, also after a refresh, may create, change or delete files only there, on top of what the person's role allows.
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 |