Agent API keys
An agent key lets an agent, a script or a server publish and manage the sites 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.
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 one site or on all your sites.
- An uploaded site's Settings page → Agent publishing for a key tied to that site. Only uploaded sites (published from a zip, like a claimed drop) can be managed with a key; repo-connected sites and App VM apps cannot.
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:
Sites. This site (one site) or All sites (every uploaded site in the account). A key for one site can only reach that site; any other
projectIdanswers404.Scopes. One or more of:
scope lets the key publishpublish a new version of a site, read a site, and (all-sites keys) list sites createcreate a new site. A createkey is always an all-sites key.domainsattach, check and detach custom domains deletedelete a 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.
Creating a key with create, domains or delete takes a signature from you. With it, Stasho's delegate writes your site 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 publish-only key needs no signature.
Every key's recent use is listed beside it, 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.
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_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
zip -r site.zip ./dist/. # index.html must be at the zip root
curl -H "Authorization: Bearer $STASHO_KEY" \
-F artifact=@site.zip -F name="Launch page" \
https://api.stasho.xyz/api/agent/sites201, once the site is live:
{
"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. |
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
curl -H "Authorization: Bearer $STASHO_KEY" \
-F artifact=@site.zip \
https://api.stasho.xyz/api/agent/sites/$PROJECT_ID/publish200, once the new version is live:
{
"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
curl -H "Authorization: Bearer $STASHO_KEY" \
https://api.stasho.xyz/api/agent/sites/$PROJECT_ID200:
{
"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 it serves the current version and pending until then; records are the three DNS records it needs.
List sites
GET /api/agent/sites · scope publish, all-sites key only
curl -H "Authorization: Bearer $STASHO_KEY" https://api.stasho.xyz/api/agent/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
curl -H "Authorization: Bearer $STASHO_KEY" \
-H "Content-Type: application/json" \
-d '{"host": "www.example.com"}' \
https://api.stasho.xyz/api/agent/sites/$PROJECT_ID/domains201:
{
"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. 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
curl -H "Authorization: Bearer $STASHO_KEY" \
https://api.stasho.xyz/api/agent/sites/$PROJECT_ID/domains/www.example.com200: { "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
curl -X DELETE -H "Authorization: Bearer $STASHO_KEY" \
https://api.stasho.xyz/api/agent/sites/$PROJECT_ID/domains/www.example.com204, 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
curl -X DELETE -H "Authorization: Bearer $STASHO_KEY" \
https://api.stasho.xyz/api/agent/sites/$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.
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.
| status | error values | what to do |
|---|---|---|
| 200 / 201 / 204 | done | |
| 400 | zip (missing artifact, unsafe zip, no root index.html), subdomain_unsupported, not_upload_site, bad_host, project_required | fix the request |
| 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 | the site or domain does not exist for this key |
| 409 | owner_action_required, already_attached, verified_binding, or kind: "domainAttached" (the domain is on another site, whose projectId the body names) | the owner acts in the dashboard: owner_action_required means they sign in once and re-authorize publishing |
| 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 |
| 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.
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 or delete per account at once (429 wallet_busy) |
| daily budget, one-site key | 10 publishes, 100 MB extracted, 1 delete or detach |
| daily budget, all-sites key | 100 publishes or creates, 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 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.
Using this page as a prompt
Paste this page into a chat agent's instructions with the key, or use the prompt the dashboard shows when you create a key: it names the calls the key's scopes allow and links here. 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; - ask before every delete or detach.