Appearance
Agent API keys
An agent key lets an agent, a script or a server run the projects in your Stasho account over plain HTTPS, without signing in. This page is the whole contract: every call, what it returns, every error and every limit. It is written so you can paste it into a chat agent as its instructions, together with the key.
Base URL: https://api.stasho.xyz. Every call below is relative to it.
Scope of the rail, stated once: a key reaches every kind of project you own, but each kind allows different verbs. Three kinds exist, and GET /api/agent/projects tells them apart with kind:
kind | what it is | verbs a key can use |
|---|---|---|
upload | a site published from a zip (a claimed drop, or a site a key created) | publish, create, domains, delete, read, rollback |
repo | a static site built from a GitHub repo | read, deploy, rollback, domains |
app | an App VM app | read, deploy |
An app project's rollback is the one exception inside those scopes: it needs a key that signs for itself (details). What the rail never does: create, change or delete a repo project or an App VM app, set environment variables or build settings, or open a pull request on your repo. How keys are created, listed and revoked in the dashboard is in Keys & authorizations.
Prompt for your agent
Copy this into Claude Code, Cursor or any agent that can run shell commands.
text
Read https://docs.stasho.xyz/reference/agent-api.md and follow it to manage my Stasho projects.
My agent key (stasho_ak_…) is in the STASHO_AGENT_KEY environment variable: never ask me to
paste it in chat, and never write it into a file, code or a commit. Send every request from a
server-side tool with the key in the Authorization header. Start with GET /api/agent/projects
(a one-project key: read that project) and use each project's kind to pick the verb: publish
for upload, deploy for repo and app.
After each create or publish, tell me the url (it changes per version) and offer a custom
domain for a stable address. Read every error's message to me and stop on 401, 402, 403 and
409: those need me (a one-project key gets 403 wallet_key_required on the list: read that
project instead). Ask me before every delete, detach and rollback.What a key is
You create a key in the dashboard, in one of two places:
- Settings → Authorizations → Agent keys for a key that works on all your sites.
- A project's Settings page → Agent access, on any kind of project, for a key tied to that one project.
The key (stasho_ak_…) is shown once, when you create it. Stasho stores only a hash of it, so a lost key cannot be shown again: revoke it and create a new one, or rotate it from the same list.
Each key has:
Projects. This project (one project) or All sites (every project in the account). A key for one project can only reach that project; any other
projectIdanswers404.Scopes. One or more of:
scope lets the key readlist projects, read a project and list its deployments (the project calls). Not implied by any other scope. deployredeploy a repo project or an App VM app rollbackroll a project back to an earlier live version, and undo it publishpublish a new version of an uploaded site, read it, and (all-sites keys) list uploaded sites createcreate a new uploaded site. A createkey is always an all-projects key.domainsattach, check and detach custom domains on an uploaded site or a repo static site deletedelete an uploaded site Expiry. 1, 7, 30 or 90 days, or never. A
deletekey must expire within 90 days, and needs an email on file for the account.
A key for one project holds only the scopes its kind allows (the table above). Minting one with another scope answers 400 scope_not_for_kind. The kind is checked again at every call, so a project whose kind changed after the key was made gets use_publish or instance_domains_unsupported instead of a surprise.
Creating a key with create, domains, delete or rollback takes a signature from you. With it, Stasho's delegate writes your project records on your behalf when such a key asks. That grant is listed under Settings → Authorizations, and withdrawing it there revokes every key that carries one of those scopes. A key with only publish, read or deploy needs no signature.
Each key shows when it was last used, and each build a key publishes shows the key's name in the site's deployment list. If the account had an email on file when a domains or delete key was created, Stasho emails that address each time the key deletes a site or detaches a domain.
Two ways to sign site records
Creating, attaching a domain to, detaching a domain from, deleting a site, rolling back and undoing a rollback each write a project record. A key can have those records signed in one of two ways, chosen when you create it.
The grant door. You sign once, and Stasho's delegate writes the records when the key asks. This is the default, described above: every call below answers with its result directly.
The signing-key door. In the create-key dialog, choose My agent signs and paste the agent's public signing key (an uncompressed secp256k1 key: 0x04 followed by 128 hex characters). The key must be an all-projects key with at least one of create, domains, delete and rollback. Your agent signs its own site records. The signing key can create, change and remove every site record of this wallet directly on the network, whatever the key's scopes, until its row is revoked or expires. Stasho still publishes the files and marks versions live, as it does from the dashboard, and gains no new permission over your sites. A key with the domains scope also lets Stasho write the domain's routing entry, the same permission the dashboard grants when you attach a domain. On such a key, the record calls (create, attach, detach, delete, rollback and undo) answer 202 with something to sign, and the agent finishes each one with one more call. publish, deploy and the read calls work as before. The key's signing address is listed under Settings → Authorizations, and revoking that row ends the agent's ability to sign.
Authenticate
Send the key as a bearer token on every request:
Authorization: Bearer stasho_ak_…Call the API server-side only: from a script, a CLI tool, a server or a Worker. Browser access from other origins is not enabled (no CORS), so a web page cannot call it, and a key in a web page would be readable by anyone who opens it. Don't write a key into files, code or commits; read it from an environment variable or a secret store.
The examples below read the key from $STASHO_AGENT_KEY.
Calls
A site is addressed by its projectId. A create response returns it; an all-sites key can also list its sites.
Create a site
POST /api/agent/sites · scope create
bash
rm -f site.zip; (cd dist && zip -qr ../site.zip .)
curl -H "Authorization: Bearer $STASHO_AGENT_KEY" \
-F artifact=@site.zip -F name="Launch page" \
https://api.stasho.xyz/api/agent/sitesWith the CLI:
bash
stasho create ./dist --name "Launch page"201, once the site is live:
json
{
"projectId": "…",
"deploymentId": "…",
"url": "https://…",
"cid": "bafy…",
"name": "Launch page"
}| field | required | notes |
|---|---|---|
artifact | yes | a .zip with index.html at its root |
name | no | the site's name in the dashboard; Untitled site when absent. It is stored encrypted for the owner, so no read call returns it. |
What you upload is public and cannot be taken back, so zip the built site rather than the project root. On create and publish alike, .git is left out at any depth, and a .env or .env.* file at any depth refuses the upload, with a sentence naming the file in message. Every other dotfile, .well-known included, is published.
A site created with a key has no default subdomain. url is the gateway URL of this version, and it changes with every version you publish. For a stable address, attach a custom domain. A subdomain field is refused with 400 subdomain_unsupported.
The site is paid for by the account's hosting credits. If the account is out of credits, the call answers 402 credits and nothing is created.
If the call answers 500 deployment_record_failed, the site was created but its first version was not recorded. The body carries its projectId: publish to that site instead of creating another one.
Publish a new version
POST /api/agent/sites/<projectId>/publish · scope publish
bash
rm -f site.zip; (cd dist && zip -qr ../site.zip .)
curl -H "Authorization: Bearer $STASHO_AGENT_KEY" \
-F artifact=@site.zip \
https://api.stasho.xyz/api/agent/sites/$PROJECT_ID/publishWith the CLI:
bash
stasho publish ./dist --site $PROJECT_ID200, once the new version is live:
json
{
"deploymentId": "…",
"url": "https://…",
"cid": "bafy…",
"domains": ["www.example.com"]
}url is the new version's gateway URL. domains lists the custom domains attached to the site; they switch to the new version within a few minutes.
A key for one site can also publish with POST /api/drop, the anonymous drop endpoint, by adding its bearer header: the request publishes to the key's own site (a projectId in the body is ignored) and returns the same body as above. An all-sites key on /api/drop answers 400 project_required; use the publish call above.
Read a site
GET /api/agent/sites/<projectId> · scope publish
bash
curl -H "Authorization: Bearer $STASHO_AGENT_KEY" \
https://api.stasho.xyz/api/agent/sites/$PROJECT_IDWith the CLI:
bash
stasho site $PROJECT_ID200:
json
{
"projectId": "…",
"url": "https://…",
"latestDeployment": { "id": "…", "status": "live", "cid": "bafy…", "url": "https://…" },
"domains": [
{ "host": "www.example.com", "status": "live", "records": [ … ] }
]
}url is the version being served now (null before the first one). latestDeployment is the newest version (null when there is none); its status is live once published. Each domain's status is live when its pointer is on the current version and pending until then; that is before the gateway serves it (minutes, sometimes 40 or more), so check a build with its url, not the domain. records are the three DNS records it needs.
List sites
GET /api/agent/sites · scope publish, all-sites key only
bash
curl -H "Authorization: Bearer $STASHO_AGENT_KEY" https://api.stasho.xyz/api/agent/sitesWith the CLI:
bash
stasho sites200: { "sites": [ … ] }, one entry per uploaded site in the account, each shaped like the read above. A key for one site answers 403 wallet_key_required; read the site instead.
Attach a custom domain
POST /api/agent/sites/<projectId>/domains · scope domains
bash
curl -H "Authorization: Bearer $STASHO_AGENT_KEY" \
-H "Content-Type: application/json" \
-d '{"host": "www.example.com"}' \
https://api.stasho.xyz/api/agent/sites/$PROJECT_ID/domainsWith the CLI:
bash
stasho domain add www.example.com --site $PROJECT_ID201:
json
{
"host": "www.example.com",
"status": "pending",
"records": [
{ "type": "CNAME", "name": "www.example.com", "value": "ipfs.public.aleph.sh" },
{
"type": "CNAME",
"name": "_dnslink.www.example.com",
"value": "_dnslink.www.example.com.static.public.aleph.sh"
},
{ "type": "TXT", "name": "_control.www.example.com", "value": "0x…" }
]
}host must be a hostname with at least one dot. The three domain calls take the projectId of an uploaded site or of a repo project (a static site built from GitHub), and the domain then follows that project's live version. An App VM project answers 400 instance_domains_unsupported: its domains come from the VM. Attaching only registers the domain: it goes live once the three records are set in the domain's DNS. See Custom domains.
Check a domain's DNS
GET /api/agent/sites/<projectId>/domains/<host> · scope domains
bash
curl -H "Authorization: Bearer $STASHO_AGENT_KEY" \
https://api.stasho.xyz/api/agent/sites/$PROJECT_ID/domains/www.example.comWith the CLI:
bash
stasho domain check www.example.com --site $PROJECT_ID200: { "ok": true } when all three records resolve as required, otherwise { "ok": false, "missing": [ … ] } naming what is wrong:
missing entry | meaning |
|---|---|
host_cname | the host does not point at the gateway (no CNAME to it, and none of its A records are the gateway's) |
host_foreign_ip | the host reaches the gateway but also has an A or AAAA record for another address, so some visitors land elsewhere |
host_ipv6_unchecked | the host's IPv4 side is right, but its IPv6 records could not be read |
dnslink_cname | the _dnslink.<host> CNAME is missing or points elsewhere |
control_txt | the _control.<host> TXT record is missing or holds another value |
host_foreign_ip and host_ipv6_unchecked do not hold the domain back; the others do. A domain is live when the site read shows it as live. A passing check also restarts activation, so the domain reads live within about 15 minutes of it, usually sooner. If it still reads pending after that, run the check again, or press Re-check now on the domain in the dashboard.
Detach a domain
DELETE /api/agent/sites/<projectId>/domains/<host> · scope domains
bash
curl -X DELETE -H "Authorization: Bearer $STASHO_AGENT_KEY" \
https://api.stasho.xyz/api/agent/sites/$PROJECT_ID/domains/www.example.comWith the CLI:
bash
stasho domain remove www.example.com --site $PROJECT_ID204, no body. A detach counts against the key's daily delete budget (see Limits). You can remove the DNS records afterwards.
Delete a site
DELETE /api/agent/sites/<projectId> · scope delete
bash
curl -X DELETE -H "Authorization: Bearer $STASHO_AGENT_KEY" \
https://api.stasho.xyz/api/agent/sites/$PROJECT_IDWith the CLI:
bash
stasho delete --site $PROJECT_ID204, no body. The site's records are removed and every key tied to that site is revoked. An agent should ask its user before every delete.
Projects
The calls above run uploaded sites. The calls below run every kind of project: they read, redeploy, roll back and undo. A project is addressed by its projectId, and a response carries ids and public fields only. It never returns a project's name, repo, branch, environment variables, commit, commit message, build error text or Actions run URL.
List projects
GET /api/agent/projects · scope read, all-projects key only
bash
curl -H "Authorization: Bearer $STASHO_AGENT_KEY" https://api.stasho.xyz/api/agent/projectsWith the CLI:
bash
stasho projects200: { "projects": [ … ] }, one entry per project in the account, each shaped like the read below. A key for one project answers 403 wallet_key_required; read the project instead.
Read a project
GET /api/agent/projects/<projectId> · scope read
bash
curl -H "Authorization: Bearer $STASHO_AGENT_KEY" \
https://api.stasho.xyz/api/agent/projects/$PROJECT_IDWith the CLI:
bash
stasho project --project $PROJECT_ID200:
json
{
"projectId": "…",
"kind": "repo",
"url": "https://…",
"latestDeployment": { "id": "…", "status": "live", "cid": "bafy…", "url": "https://…" },
"currentDeploymentId": null,
"domains": [
{ "host": "www.example.com", "status": "live", "records": [ … ] }
]
}kindisupload,repoorapp. It names the verb that ships a version:publishforupload,deployforrepoandapp.urlis what the project serves now: the gateway URL of the live version foruploadandrepo, andhttps://plus the default subdomain, or the first custom domain, forapp. It isnullwhen there is none yet.latestDeploymentis the newest version (nullwhen there is none). For anappproject itsurlisnull.currentDeploymentIdis the version a rollback pinned, while that pin is what the project serves: it is the entry withlive: truein the deployment list. It isnullwhen no pin is in force, including a pin a newer build has superseded.- On
uploadandrepo, a domain'sstatusislivewhen its pointer is on the current version (before the gateway serves it) andpendinguntil then, andrecordsare the three DNS records. Onapp,statusis alwaysnull, because a domain's state lives on the VM, andrecordsare two: aCNAMEfrom the host to the VM's address, and the_control.<host>TXTrecord holding the account's address.
List deployments
GET /api/agent/projects/<projectId>/deployments · scope read
bash
curl -H "Authorization: Bearer $STASHO_AGENT_KEY" \
https://api.stasho.xyz/api/agent/projects/$PROJECT_ID/deploymentsWith the CLI:
bash
stasho deployments --project $PROJECT_ID200, newest first:
json
{
"deployments": [
{
"id": "…",
"status": "live",
"createdAt": "2026-10-31T09:00:00.000Z",
"finishedAt": "2026-10-31T09:01:10.000Z",
"cid": "bafy…",
"url": "https://…",
"runId": 123456789,
"live": true
}
]
}live: true marks the one version serving now. runId is the GitHub Actions run that built the version, and null for a version no run built (an uploaded site's, or an older record). It is how an agent finds the deployment its own redeploy produced.
Redeploy a project
POST /api/agent/projects/<projectId>/deploy · scope deploy
bash
curl -X POST -H "Authorization: Bearer $STASHO_AGENT_KEY" \
https://api.stasho.xyz/api/agent/projects/$PROJECT_ID/deployWith the CLI:
bash
stasho deploy --project $PROJECT_ID202:
json
{ "projectId": "…", "runId": 123456789 }Redeploying runs the project's own deploy workflow on its production branch, which builds the latest commit, the same as Redeploy in the dashboard. It works on repo and app projects. The call answers when the workflow is dispatched, not when the build is done: poll the deployment list until the entry with this runId has status: "live" (a failed build reads failed). After about 15 minutes, stop and tell the owner the build has not finished, not that it failed: a slow build can still go live, and a run that dies before its first report creates no deployment row, so the entry may never appear. awaiting_resign means the owner must re-authorize in the dashboard, and on an App VM app pinned means the build is published and waits for the VM to pick it up. Neither is live or failed: stop polling and tell the owner. runId can be null when GitHub returns no run details; the redeploy still runs, and the agent then watches for a new deployment instead.
The call never opens a pull request or writes to your repo. When the repo's setup is not finished it stops and says so:
- an uploaded site answers
400 use_publish: publish a zip instead; - the deploy workflow is missing from the branch, or names another project, answers
409 setup_required: finish setup in the dashboard; - Stasho cannot reach the repo (the GitHub connection is gone or cannot be recovered) answers
409 repo_unreachable: reconnect GitHub for this project in the dashboard; - GitHub did not answer, or refused the dispatch, answers
502 github_unavailable: retry in a minute.
Rollback and undo
POST /api/agent/projects/<projectId>/rollback · scope rollback, body { "deploymentId": "…" }
POST /api/agent/projects/<projectId>/rollback/undo · scope rollback
bash
curl -H "Authorization: Bearer $STASHO_AGENT_KEY" -H "Content-Type: application/json" \
-d "{\"deploymentId\": \"$DEPLOYMENT_ID\"}" \
https://api.stasho.xyz/api/agent/projects/$PROJECT_ID/rollback
curl -X POST -H "Authorization: Bearer $STASHO_AGENT_KEY" \
https://api.stasho.xyz/api/agent/projects/$PROJECT_ID/rollback/undoWith the CLI:
bash
stasho rollback $DEPLOYMENT_ID --project $PROJECT_ID
stasho rollback undo --project $PROJECT_ID201:
json
{ "projectId": "…", "currentDeploymentId": "…" }A rollback re-points the project to an earlier version without a rebuild, the same as Roll back to this in the dashboard. The target must be a deployment of this project with status: "live" and a stored build, and live: false in the list (the entry with live: true is already serving), from the deployment list; anything else answers 409 not_rollbackable. Rolling back to the build already serving is accepted and charged, and it pins that build, so a build already running at that moment does not serve when it finishes. An agent that wants a different build must pick an entry with live: false. Undo clears the pin, so the newest live version serves again, and answers 201 with currentDeploymentId: null. Undo acts on the stored pin, not on what the read shows: a pin a newer build has superseded reads as null and is still stored, so undo clears it, answers 201 and is charged as usual. A project with no stored pin answers 409 not_pinned.
On an upload or repo project the domains' pointer moves within seconds, but a domain serves the new build only after the gateway republishes, which takes minutes and sometimes 40 or more (see custom domains). Do not judge a rollback by fetching the domain: verify through the live flag or the deployment's own url from the list, which is addressed by its CID, and do not retry the call or roll back again. After a rollback live moves to the target in the deployment list and currentDeploymentId names it. A newer build supersedes a rollback on its own: once the next deploy or publish is live, the project serves that build and currentDeploymentId reads null. The old pin stays stored until an undo clears it. Two kinds of build do not supersede it: one that fails, and one already running when you rolled back, which finishes live without serving (failed on an App VM). On a key that signs, the rollback's time is set at the 202, so a build created between the 202 and the commit counts as newer: if it is live by then, the 201 names your target while the read answers null and live sits on that build.
On an App VM, an agent can only roll back with a key that signs for itself. The VM reads a rollback only from the owner or from a key with its own signing key, not from Stasho's delegate. Any other key answers 400 app_rollback_needs_signer on rollback and rollback/undo, and nothing is changed or charged. Use an All sites key created with My agent signs (which needs the rollback scope), or ask the owner to roll back in the dashboard. A project key on an App VM cannot carry rollback at all, since a project key cannot hold a signing key. This holds for any project that deploys to an App VM, whatever it is built from.
On a key created with My agent signs, a rollback and an undo answer 202 with something to sign, as in the next section, and the 201 comes from the commit call. A 202 is good only while the project is unchanged: if it changed in between, the commit answers 409 stale.
Sign your own records
On a key created with My agent signs, create a site, attach a domain, detach a domain, delete a site, roll back and undo do not finish on the first call. Each answers 202:
json
{
"status": "signature_required",
"clientRef": "…",
"toSign": "…",
"signer": "0x…",
"expiresAt": "2026-10-30T12:05:00.000Z",
"unsignedMessage": { … }
}unsignedMessage is the record message itself, so a client can rebuild toSign (sha256 of the JSON content, then ETH, the signer, the type and that hash joined by newlines) and check what it signs; the CLI does.
An attach also carries host. toSign is the verification text of the message that writes the site record, not the record itself. Sign it as an EIP-191 personal message with the private key that matches the key's public signing key, then send the signature to the commit call with the same bearer header:
POST /api/agent/commit
bash
curl -H "Authorization: Bearer $STASHO_AGENT_KEY" \
-H "Content-Type: application/json" \
-d "{\"clientRef\": \"$CLIENT_REF\", \"signature\": \"$SIGNATURE\"}" \
https://api.stasho.xyz/api/agent/commitThe answer is the call's normal result: 201 with the body shown above for a create, an attach, a rollback and an undo, 204 for a detach and a delete. A clientRef is good for 5 minutes and works only with the key that received it.
In Node, or in a Worker, with ethers v6:
js
import { Wallet } from "ethers"; // v6
const signer = new Wallet(process.env.STASHO_SIGNING_KEY);
const res = await fetch(`${API}/api/agent/sites`, { method: "POST", headers, body: form });
const { clientRef, toSign } = await res.json(); // the 202
const signature = await signer.signMessage(toSign); // EIP-191
const done = await fetch(`${API}/api/agent/commit`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ clientRef, signature }),
});The CLI runs this loop itself and checks the message before signing it.
Keep the signing private key apart from the Stasho key, and never print either.
Status codes
Every error body is JSON with an error field; most also carry a message to show a person. On the publish call, the errors about the upload itself (a missing, unsafe or too large zip, a site that is not found or not an uploaded site) carry that sentence in error instead of a code. A zip that was read and refused carries it in message as well.
| status | error values | what to do |
|---|---|---|
| 200 / 201 / 204 | done | |
| 202 | status: "signature_required" (signing-key keys only), or { projectId, runId } from a redeploy | sign toSign and commit it; after a redeploy, poll the deployment list |
| 400 | zip (missing artifact, unsafe zip, no root index.html, a .env file), subdomain_unsupported, not_upload_site (the call is for uploaded sites; use the project calls or, for domains, a repo static site), bad_host, project_required, bad_signature (the commit's signature is not from the key's signing key), use_publish (redeploy of an uploaded site: publish a zip), instance_domains_unsupported (domains on an App VM project: its domains come from the VM), app_rollback_needs_signer (rollback or undo on an App VM with a key that does not sign for itself), scope_not_for_kind (minting a project key with a scope its kind does not allow) | fix the request; for app_rollback_needs_signer, use a signing key or ask the owner |
| 413 | zip (too large), or reason: "upload_too_large" over 50 MB | send a smaller zip |
| 401 | key_invalid | the key is unknown, expired or revoked; ask the owner for a new one |
| 402 | credits | the account is out of hosting credits; ask the owner to top up from the dashboard |
| 403 | scope_missing (with the scope it lacks), wallet_key_required, terms-not-accepted, or reason: "account-suspended" | the key cannot do this; the owner creates a key with the scope, or signs in to the dashboard |
| 404 | not_found, host_not_attached, not_attached, unknown_ref (the commit's clientRef is unknown, older than 5 minutes, or belongs to another key) | the site or domain does not exist for this key; after unknown_ref, request the write again |
| 409 | setup_required, repo_unreachable, not_rollbackable, not_pinned, owner_action_required, already_attached, verified_binding, or kind: "domainAttached" (the domain is on another site, whose projectId the body names), signer_not_authorized, stale | the owner acts in the dashboard: setup_required means the project's deploy workflow is not set up on its branch, and repo_unreachable that GitHub must be reconnected for the project. not_rollbackable means the target is not a live deployment of this project with a stored build: pick another from the deployment list. not_pinned means there is no rollback to undo. owner_action_required means they sign in once and re-authorize publishing. signer_not_authorized means the signing key's authorization was revoked, or authorizations changed since the 202: request the write again, and ask the owner if it repeats. stale means the site changed since the 202: request the write again |
| 429 | budget, wallet_busy, busy, kind: "quota" (with operation, limit, current, resetAt), kind: "rateLimit" (with a Retry-After header) | wait: budget resets at UTC midnight, wallet_busy and busy clear when the change in progress ends |
| 500 | failed, deployment_record_failed (with the created projectId) | retry in a minute; after deployment_record_failed, publish to the named site instead of creating again |
| 502 | github_unavailable | GitHub did not answer. A failed pre-check spends nothing; a dispatch GitHub refused still spends one deploy. Retry in a minute |
| 503 | retry_later, busy, dns_check_failed | retry in a minute; retry_later and dns_check_failed carry Retry-After: 30 |
Custom domains
A site created with a key has no default subdomain, and its gateway URL changes with every version. A custom domain is the stable address: attach it once and it follows every later publish.
Attaching returns three DNS records to create at your DNS provider. Copy the values exactly as the response gives them:
| type | name | value |
|---|---|---|
| CNAME | www.example.com | ipfs.public.aleph.sh |
| CNAME | _dnslink.www.example.com | _dnslink.www.example.com.static.public.aleph.sh |
| TXT | _control.www.example.com | the account's address, as returned |
A bare domain (example.com) cannot carry a CNAME, so its first record comes back as type ALIAS with a note: use your provider's ALIAS, ANAME or CNAME flattening with that value, or an A record to the address that value resolves to.
The records must answer directly, not through a proxy: the check looks for the gateway's own name or addresses at the host. Then poll the domain check until it passes, and the site read until the domain shows live, within about 15 minutes of the passing check. Stasho stops checking a pending domain's DNS on its own 30 minutes after it is attached; a passing check, or Re-check now on the domain in the dashboard, starts it again.
A Worker example
This Cloudflare Worker module creates a site, attaches a domain, writes the three records through the Cloudflare DNS API and waits for the domain to go live. It expects STASHO_KEY (an all-sites key with create, publish and domains), CF_API_TOKEN (a token with DNS edit on the zone) and CF_ZONE_ID as secrets.
The records are created DNS-only (proxied: false, the grey cloud). That is required: a proxied record answers with Cloudflare's addresses, so the domain check never passes. What it costs: that host gets no Cloudflare proxy, no WAF and no Cloudflare cache. Visitors reach the gateway directly.
js
const API = "https://api.stasho.xyz/api/agent";
const CF = "https://api.cloudflare.com/client/v4";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const NON_BLOCKING = ["host_foreign_ip", "host_ipv6_unchecked"];
async function stasho(env, path, init = {}) {
const headers = { Authorization: `Bearer ${env.STASHO_KEY}`, ...init.headers };
const res = await fetch(`${API}${path}`, { ...init, headers });
if (!res.ok) throw new Error(`${path}: ${res.status} ${await res.text()}`);
return res.status === 204 ? null : res.json();
}
async function setRecord(env, record) {
const type = record.type === "ALIAS" ? "CNAME" : record.type;
const body = { type, name: record.name, content: record.value, ttl: 1 };
if (type === "CNAME") body.proxied = false;
const res = await fetch(`${CF}/zones/${env.CF_ZONE_ID}/dns_records`, {
method: "POST",
headers: { Authorization: `Bearer ${env.CF_API_TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`DNS ${record.name}: ${res.status} ${await res.text()}`);
}
async function until(check, { tries = 30, everyMs = 20_000 } = {}) {
for (let i = 0; i < tries; i++) {
if (await check()) return;
await sleep(everyMs);
}
throw new Error("timed out");
}
export async function launch(env, zip, host) {
const form = new FormData();
form.append("artifact", zip, "site.zip");
const site = await stasho(env, "/sites", { method: "POST", body: form });
const domain = await stasho(env, `/sites/${site.projectId}/domains`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ host }),
});
for (const record of domain.records) await setRecord(env, record);
await until(async () => {
const check = await stasho(env, `/sites/${site.projectId}/domains/${host}`);
return check.ok || check.missing.every((key) => NON_BLOCKING.includes(key));
});
await until(
async () => {
const read = await stasho(env, `/sites/${site.projectId}`);
return read.domains.some((d) => d.host === host && d.status === "live");
},
{ tries: 45 },
);
return { projectId: site.projectId, url: `https://${host}` };
}The DNS wait also accepts a check whose only missing entries are host_foreign_ip or host_ipv6_unchecked: the domain activates with those, so ok alone would never come.
Waiting for DNS can take several minutes, so call launch from a Cron Trigger, a Queue consumer or a Workflow rather than inside a visitor's request. On a later run, publish with POST /sites/<projectId>/publish and the domain follows on its own.
Limits
These are operational values, not contract; they can change.
| limit | value |
|---|---|
| live keys | 10 per site, 25 per account |
| requests | 30 per minute per IP address, 60 per minute per key |
| create: zip size | 10 MB compressed, 40 MB extracted, 1,000 files |
| publish: zip size | 50 MB compressed, 200 MB extracted, 10,000 files |
| one change at a time | one publish, create, detach, delete, rollback or undo per account at once (429 wallet_busy) |
| uploads in flight | three publishes or creates at once across all accounts (503 busy, Retry-After: 30) |
| daily budget, one-project key | 10 deploys (publishes, redeploys, rollbacks and undos together), 100 MB extracted, 1 delete or detach |
| daily budget, all-projects key | 100 deploys (publishes, creates, redeploys, rollbacks and undos together), 1 GB extracted, 10 deletes or detaches |
| account quotas | 20 sites; 100 deploys and 1,024 MB of zips per day, shared with the dashboard |
A create you never commit still spends one of the key's deploys and its extracted bytes for the day.
A redeploy, a rollback and an undo each spend one of the key's daily deploys, the same count as a publish. A rollback or undo that is refused spends nothing. A redeploy whose dispatch GitHub refused or dropped still spends its deploy. The reads spend nothing.
A key's daily budget resets at UTC midnight. A refusal before the upload is staged spends nothing, and a delete or detach counts only once it succeeds. A zip refused once staged (no index.html, a .env file, or over the extracted cap) still spends one of the key's deploys for the day: the budget is reserved when the upload is admitted.
Using this page as a prompt
The prompt at the top of this page, or the one the dashboard shows when you create a key (it names the calls the key's scopes allow and links here), is the whole brief. An agent following it should:
- send every request from a server-side tool, with the key in the
Authorizationheader, and never write the key into a file, code, a commit or its reply; - tell its user the
urlafter each create or publish, and that it changes per version; offer a custom domain for a stable address; - read every error's
messageto its user, and stop on401,402,403and409: those need the owner; - start with
GET /api/agent/projects(a one-project key reads that project instead, as the list answers it403 wallet_key_required), usekindto choosepublishordeploy, and after a redeploy poll the deployment list for the entry with itsrunId, giving up after about 15 minutes and telling its user; - ask before every delete, detach and rollback.