Skip to content

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.

Scope of the rail, stated once: a key manages uploaded sites only, the sites published from a zip (a claimed drop, or a site a key created). Repo-connected projects and App VM apps are not reachable with a key; for those, an agent hands the owner the browser steps, which each guide spells out under "Working with an agent". 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 sites.
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. 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. Ask me before every
delete or detach.

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.

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 projectId answers 404.

  • Scopes. One or more of:

    scopelets the key
    publishpublish a new version of a site, read a site, and (all-sites keys) list sites
    createcreate a new site. A create key 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 delete key 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.

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 and deleting a site each write a site 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-sites key with at least one of create, domains and delete. Your agent signs its own site records. 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 four record calls answer 202 with something to sign, and the agent finishes each one with one more call. publish 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_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
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/sites

201, once the site is live:

json
{
  "projectId": "…",
  "deploymentId": "…",
  "url": "https://…",
  "cid": "bafy…",
  "name": "Launch page"
}
fieldrequirednotes
artifactyesa .zip with index.html at its root
namenothe 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

bash
curl -H "Authorization: Bearer $STASHO_KEY" \
     -F artifact=@site.zip \
     https://api.stasho.xyz/api/agent/sites/$PROJECT_ID/publish

200, 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_KEY" \
     https://api.stasho.xyz/api/agent/sites/$PROJECT_ID

200:

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 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

bash
curl -H "Authorization: Bearer $STASHO_KEY" https://api.stasho.xyz/api/agent/sites

200: { "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_KEY" \
     -H "Content-Type: application/json" \
     -d '{"host": "www.example.com"}' \
     https://api.stasho.xyz/api/agent/sites/$PROJECT_ID/domains

201:

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. 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_KEY" \
     https://api.stasho.xyz/api/agent/sites/$PROJECT_ID/domains/www.example.com

200: { "ok": true } when all three records resolve as required, otherwise { "ok": false, "missing": [ … ] } naming what is wrong:

missing entrymeaning
host_cnamethe host does not point at the gateway (no CNAME to it, and none of its A records are the gateway's)
host_foreign_ipthe host reaches the gateway but also has an A or AAAA record for another address, so some visitors land elsewhere
host_ipv6_uncheckedthe host's IPv4 side is right, but its IPv6 records could not be read
dnslink_cnamethe _dnslink.<host> CNAME is missing or points elsewhere
control_txtthe _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_KEY" \
     https://api.stasho.xyz/api/agent/sites/$PROJECT_ID/domains/www.example.com

204, 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_KEY" \
     https://api.stasho.xyz/api/agent/sites/$PROJECT_ID

204, 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.

Sign your own records ​

On a key created with My agent signs, create a site, attach a domain, detach a domain and delete a site 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"
}

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_KEY" \
     -H "Content-Type: application/json" \
     -d "{\"clientRef\": \"$CLIENT_REF\", \"signature\": \"$SIGNATURE\"}" \
     https://api.stasho.xyz/api/agent/commit

The answer is the call's normal result: 201 with the body shown above for a create and an attach, 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 }),
});

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.

statuserror valueswhat to do
200 / 201 / 204done
202status: "signature_required" (signing-key keys only)sign toSign and commit it
400zip (missing artifact, unsafe zip, no root index.html), subdomain_unsupported, not_upload_site, bad_host, project_required, bad_signature (the commit's signature is not from the key's signing key)fix the request
413zip (too large), or reason: "upload_too_large" over 50 MBsend a smaller zip
401key_invalidthe key is unknown, expired or revoked; ask the owner for a new one
402creditsthe account is out of hosting credits; ask the owner to top up from the dashboard
403scope_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
404not_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
409owner_action_required, already_attached, verified_binding, or kind: "domainAttached" (the domain is on another site, whose projectId the body names), signer_not_authorized, stalethe owner acts in the dashboard: 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
429budget, 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
500failed, deployment_record_failed (with the created projectId)retry in a minute; after deployment_record_failed, publish to the named site instead of creating again
503retry_later, busy, dns_check_failedretry 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:

typenamevalue
CNAMEwww.example.comipfs.public.aleph.sh
CNAME_dnslink.www.example.com_dnslink.www.example.com.static.public.aleph.sh
TXT_control.www.example.comthe 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.

limitvalue
live keys10 per site, 25 per account
requests30 per minute per IP address, 60 per minute per key
create: zip size10 MB compressed, 40 MB extracted, 1,000 files
publish: zip size50 MB compressed, 200 MB extracted, 10,000 files
one change at a timeone publish, create, detach or delete per account at once (429 wallet_busy)
uploads in flightthree publishes or creates at once across all accounts (503 busy, Retry-After: 30)
daily budget, one-site key10 publishes, 100 MB extracted, 1 delete or detach
daily budget, all-sites key100 publishes or creates, 1 GB extracted, 10 deletes or detaches
account quotas20 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 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, 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 Authorization header, and never write the key into a file, code, a commit or its reply;
  • tell its user the url after each create or publish, and that it changes per version; offer a custom domain for a stable address;
  • read every error's message to its user, and stop on 401, 402, 403 and 409: those need the owner;
  • ask before every delete or detach.