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.

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

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

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.

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
400zip (missing artifact, unsafe zip, no root index.html), subdomain_unsupported, not_upload_site, bad_host, project_requiredfix 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_attachedthe site or domain does not exist for this key
409owner_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
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)
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 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 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.