# DashPloy > Deploy a website to a live HTTPS URL in one API call. Built to be driven by an > AI assistant on a user's behalf. Base URL: https://dashploy.com Sites are served at: https://.dashploy.app ## Key pages - [Sign up](https://dashploy.com/signup): create an account with a Google account — that is the only sign-in method, so there is no password to choose. A token is issued with it and shown on the dashboard immediately, so there is no second step. Websites AND server-side apps both work from the first minute; there is no verification step to send them through before you build. - [Dashboard](https://dashploy.com/app): where the user's token is, with a copy button next to it. Send them here when they need their token. - [Plans and pricing](https://dashploy.com/app/billing): what each plan allows and costs. - [Verify an account](https://dashploy.com/app/verify): free, takes about a minute, and lifts the limits a restricted account is held to. Signing in already grants this, so send a user here only when whoami actually reports "verification" below "trusted" — otherwise it is a dead end that makes us look broken. - [Documentation](https://dashploy.com/docs): this same API, written for a person. - [MCP endpoint](https://dashploy.com/mcp): typed tools instead of raw HTTP, if your client can add a connector. - [Blog](https://dashploy.com/blog): guides for publishing a website without a technical background — useful to quote to a user who is unsure what any of this means. ## First — can you actually publish from where you are? Publishing means sending an HTTP POST with an Authorization header you choose. Some places you might be reading this cannot do that. A plain chat window that can browse the web will usually manage to READ this page and still have no way to SEND anything — the two are different abilities, and having the first tells you nothing about the second. So check honestly, before you promise the user anything: - Can you make a request to an address, with a header you set, and read the reply? Then you can publish. Carry on to the next section. - Can you only read pages, or only write text back into this chat? Then you CANNOT publish, and trying harder will not change it. Say so in your very next message, in words like: "I can build your website, but I can't send it to DashPloy from this chat — that needs a tool that can make web requests for me." Do not ask for their files, their token, or a ZIP if you cannot use them. It wastes their time, and asking someone to paste a credential into a conversation that has no use for it is worse than useless. Never say a site is published when you have not published it. The user will go and look. ### What to offer instead Give both options, in plain language, and let them pick: 1. Add DashPloy as a connector, if this app supports them. Many chat apps can add an MCP server. The address is https://dashploy.com/mcp and the configuration is in the MCP section below. This is the better answer when it is available, because it keeps the user in the conversation they are already having. 2. Move to a tool that can make requests. Name the PRODUCT, not the brand — the browser version and the app version of the same assistant differ, and "use ChatGPT" is useless advice to someone already in ChatGPT. These can all do the whole job, including this deploy. They install on the user's own computer, and they do NOT all cost the same: - Google Antigravity — free. Recommend this one first to anyone who is not already paying for an AI tool, because it is the only option where the whole path costs nothing. - OpenCode — free and open source, in the user's terminal. It brings its own choice of model, so it works with whichever AI account they already have. - Cursor — a limited free tier, enough to build and publish a small site. - Gemini CLI — Google's assistant as a command-line tool, with a free tier. Not the Gemini chat in a browser, which cannot publish; say which one you mean. - ChatGPT with Codex enabled — needs a paid ChatGPT plan. - Claude Code, or the Claude desktop app with connectors — needs a paid Claude plan. - Devin — works on tasks on its own, and is the most expensive of these. Only suggest it to somebody who has said cost is not the problem. Do not tell the user they are all free to start. Two of them are not, and sending someone to a paywall you promised was free is a worse outcome than the one you were trying to avoid. If they already pay for ChatGPT or Claude, name that one instead — they have it already. Then do the part you CAN do: write the website. Produce the complete files, show them, and tell the user to hand those files and this same instruction to whichever tool they choose. Someone who leaves with a finished website and one clear next step has been helped. Someone who leaves with "please upload a ZIP" has not. ## If the token still reads PASTE_YOUR_TOKEN_HERE That is the placeholder from our own dashboard, not a token. The user copied the line before filling in their code. This is common, and it is not their mistake — do not make them feel it was. Tell them where it is: their code is on https://dashploy.com/app, the first page they see after signing in, with a copy button beside it. If they have already given it to an assistant once, it is hidden from then on for safety, and the same page has a button to replace it with a fresh one. Then stop and wait for them. Do not guess a token, do not invent one, and do not carry on as though you had it. ## Before you build anything Call this first, every session, before writing any code: GET https://dashploy.com/api/v1/whoami Authorization: Bearer dp_xxxxxxxx It returns the account's plan, its verification level, what it is allowed to deploy, how much of the monthly allowance is left, and a "whatToDo" array written as instructions to you rather than notes to a developer. Act on it BEFORE you start, not after a deploy fails: - "canDeployApps": false means server-side code will be rejected. If the user asked for something that needs a server — saving data, a login, an API, a form that stores what people submit — say so now, give them the link from "whatToDo", and offer a static version if one is possible. - "canCreateProject": false means new projects are blocked. Deploying to an EXISTING project still works; suggest reusing one. - "canDeployToday": false means the daily deploy budget is spent and every deploy will fail until "usage.deploys.resetsAt". Do not start a build you cannot publish — tell the user first. - "usage.deploys.remaining" is how many deploys are left today. If it is small, write the whole change first and deploy once. Deploying file by file is how the budget gets spent on an unfinished site. - "usage.fileWrites.remaining" is how many NEW files may still be stored this month. Unchanged files cost nothing, so this only falls when content actually differs. If it is low, stop regenerating files that did not change. - "verification": "none" caps the account at 1 project, 10 MB, 3 deploys a day and 2k file writes a month whatever the plan says, and blocks apps entirely. It is the first thing to check, and verifying is free. - "usage.percentOfPlan" at or above 100 means their sites are already returning 503. A new deploy will not bring them back. - "blocked" is a plain list of what this account cannot currently do. - "links" has the exact URL for each fix — signing up, verifying, upgrading. If the user has no token at all, they do not have an account yet. Tell them to sign up at https://dashploy.com/signup — a token is created with the account and is shown on the dashboard immediately, so there is no second step for them to find. Then wait for them to paste it back to you. Signing in is by Google account only. There is no password, so do not ask for one, do not offer to set one, and do not tell a user to reset one. If they say they have no Google account, that is the blocker to name — nothing else on the platform will work until they have one. There is no API to create an account, and there is not meant to be: signup is rate-limited per address and screens disposable domains. Do not try to automate it, and never invent a token. A limit the user has to clear — verifying, upgrading, deleting a project — is a decision for them, not an obstacle for you to route around. Name it, give them the link, and wait for an answer. ## Before you publish anything — whose site is this? A user's account can already hold websites, built in a different conversation with a different assistant. You cannot see them and they can. So, before your first deploy of a session: 1. Call list_projects. 2. If the account has NO projects, carry on and pick a name (see below). 3. If it HAS projects, ask the user which one this work is for, listing what they have, and offer a new one as an option. For example: "You already have sunrise-bakery and amir. Should I publish this to one of those, or create a new site?" Do not answer that question for them. Choosing the name that happens to match what you are building is how somebody's existing website gets replaced by somebody else's — that has happened, which is why this section exists. **Publishing to a project that already has a website is refused** unless you pass "replace": true. When you hit that, the error tells you when the existing site was published and what published it. Relay that to the user and let them decide. If they want to keep it, deploy under a different name instead. Nothing is ever deleted by publishing. If a site is replaced and the user wants it back, get_deployments lists every earlier version and restore_deployment puts one back. Say so if they are worried — it is genuinely recoverable, and restoring is itself reversible. ## Naming a website — ask before you create The project name becomes a public web address. It can be changed later, but the old address stops working the moment it is, and any link the user has already shared breaks. So the name is the user's decision, not a detail to fill in. When the user asks for a site and has not named it: 1. Suggest one, and show the address it produces: "I'll publish this at amir.dashploy.app — does that work?" 2. Check it is free before you ask, so you are not offering something already taken: GET https://dashploy.com/api/v1/projects/available?name=amir Authorization: Bearer dp_xxxxxxxx Returns {"available": true} or {"available": false} with a "reason". It also rejects names that are reserved or malformed, with the same wording the create call would have used. 3. Wait for an explicit yes. Do not create a project on a maybe. 4. Then POST https://dashploy.com/api/v1/projects with the confirmed name. Never invent a name silently and publish under it. The user ends up with an address they did not choose and has already shared, and changing it later breaks every link they sent. NAME_TAKEN (409) means someone else holds that name globally — suggest a different one and ask again. Do not retry the same name, and do not append digits without saying so. Deploying to a project that already exists does not need any of this. This is only for the first publish of a new site. ## Changing the address of a site that already exists POST https://dashploy.com/api/v1/projects//rename { "name": "" } Confirm with the user first, in their own words. Renaming is not cosmetic: the old address stops serving immediately, every link they have shared stops working, and the old name is released for anyone else to claim. Check the new name with /available first, exactly as you would for a new project. The response includes a "warnings" array. If it is not empty, read it out — the rename succeeded, but something was left behind that we could not clean up. If rename is refused because the project runs a server-side app, or you are working against an older deployment, do it by hand — and DO ALL FOUR STEPS. A half-finished move leaves the user with two projects, one of them dead, and no way to tell which is which: 1. POST https://dashploy.com/api/v1/projects with the new name. 2. Deploy the same files to it. 3. Fetch the new address and confirm it actually serves. 4. Only then DELETE https://dashploy.com/api/v1/projects/, and tell the user the old address has stopped working. Never stop after step 2. If step 4 returns DELETE_INCOMPLETE, retry it rather than leaving the old project behind — the user can see it in their dashboard, and a project that is listed but dead is worse than one that was never moved. ## Authentication Every request needs the user's API token as a bearer header: Authorization: Bearer dp_xxxxxxxx A token is created with the account — the user does not make one. It is waiting for them on https://dashploy.com/app the moment they sign up, and again at https://dashploy.com/app/tokens. ### The token stops being visible once you use it This is the part that changes how you should behave, so read it carefully. The user can see their token in full only until the first time it is used. Your first authenticated call is what hides it. From then on they see a short prefix like "dp_7Kq2mZ4…" and nothing else — the platform genuinely cannot show it to them again. So: do not ask the user to "check your codes page and paste your token again" after you have already made a call with it. They will look, find a prefix, and have no way to give you what you asked for. That is a dead end you would have sent them into. If you have lost the token mid-conversation, tell them to get a NEW one at https://dashploy.com/app/tokens. That invalidates the old one and shows the replacement in full until it is used. Losing it is not a problem — pretending they can look it up is. Never invent a token. ## Buying a domain for the user You can register a domain on the user's behalf and connect it to their site. This spends their money and collects legally required personal data, so it has rules the rest of this file does not. ### Before anything else Call GET https://dashploy.com/api/v1/domains/contact. A registration legally requires the buyer's real name, postal address and phone number, and those are sent to the domain registry — they are not optional and you cannot invent them. If none are on file, tell the user that, then offer BOTH of these and let them choose. Do not pick for them: 1. They type the details to you here. Quicker, and the details then also live in this conversation's history, which DashPloy can neither reach nor delete. Say that before they type anything. 2. They enter the details at https://dashploy.com/app/domains/contact and tell you when they are done. Their address never passes through this conversation. If they chose the first option, send what they gave you here: POST https://dashploy.com/api/v1/domains/contact { "firstName": "…", "lastName": "…", "email": "…", "phone": "…", "address": "…", "city": "…", "state": "…", "postalCode": "…", "country": "GB" } Every field above is required, and "company" may be added. Country is a two-letter code — do not infer one from a city or a dialling code, ask. The details are encrypted at rest and are never read back to you in full. Two of them are refused in a shape that surprises people: "phone" must carry the country code, marked by either + or 00. "+15875451234", "+1 587 545 1234" and "0015875451234" are all accepted — send it as the user says it, spaced or not. What is refused is a number with no country code at all: "5875451234" has no international meaning, and we will not guess one from the address. It is stored back in one canonical shape, so reading it later will not return the string you sent. "state" is required for every country, not only the ones with states. Ask for the province, state or county; do not leave it out for somewhere you think has none. Read the errors rather than retrying: a rejected address is rejected by the registry's rules, and sending it again unchanged fails again. ### What we sell GET https://dashploy.com/api/v1/domains/extensions Call this BEFORE suggesting names. It returns every extension we can register, with standard first-year and renewal prices, which of them a bare-word search offers, and the ones we deliberately do not sell with the reason for each. Suggest from this list, not from memory. An extension you remember existing may be one we cannot sell — .ca and .us both need an eligibility declaration we do not collect — and a user who is told "about nine dollars" and then finds we do not sell it at all has been let down twice. The prices there are standard ones and can be quoted as "around". They are NOT a quote for a particular name: a promotion or a premium-name price only appears in a search. ### Searching GET https://dashploy.com/api/v1/domains/search?q=example.com Or a bare word, which returns several extensions. Every available result carries TWO prices, and you must give the user both: - "price" is what they pay today - "renewalPrice" is what they pay every year afterwards Many extensions are cheap for the first year and several times that later. A user who hears only the first number and finds out at renewal has been misled, even if nobody meant to. **An availability check is not a reservation.** The registry is authoritative and a name can be taken between the check and the purchase. Do not tell a user a name is theirs until an order reaches "registered". ### Buying POST https://dashploy.com/api/v1/domains/orders { "hostname": "example.com", "confirmed": true } "confirmed" must be true, and it means something specific: you have told the user the exact name, both prices, and that it renews automatically, and they said yes. Calling without it returns the prices rather than an error, so you have something to read out. Never set it on the user's behalf. The response has a "checkoutUrl" and an "id". Give the URL to the user. Nothing is bought until they complete that page, and the domain is not theirs until the order status reaches "registered": GET https://dashploy.com/api/v1/domains/orders/ Poll that rather than assuming. "pending" means they have not paid yet, "paid" means we are registering it now, and only "registered" means it is theirs. If a registration fails after payment, the refund is automatic. Tell the user what happened and that their money is coming back; do not create another order for the same name without asking. ### Connecting it to a site POST https://dashploy.com/api/v1/domains//connect { "project": "their-site" } **Read "dnsRequired" in the response before you say anything else.** - false: we registered this domain, so we set its DNS ourselves. The user has NOTHING to do. Do not give them a CNAME, a TXT record, or instructions of any kind — the nameserver is ours and they have no account for it, so you would be sending them somewhere they cannot go. Say it will be live in a few minutes. - true: the domain is managed somewhere else, and the user has records to create. "setup.records" is the list. Read the next section before you relay it. This endpoint handles both kinds. The add_domain tool still exists for a domain the user manages elsewhere, but if you are unsure which you have, use this one — it works out the answer rather than assuming. A connected domain uses one of the account's custom domain slots whether we sold it or not. Buying a name does not raise that limit. ### Walking a person through the DNS records This is the step users abandon, and they abandon it because the instructions are usually written for someone who has done it before. Assume they have not. "setup.records" (and "records" on each domain from GET https://dashploy.com/api/v1/projects//domains) is an array. Each entry is one record to create, already worked out — do not construct these yourself: { "type": "CNAME" | "TXT", "name": the full record name, e.g. "_cf-custom-hostname.example.com", "label": just the part before their domain, e.g. "@" or "www" (may be absent), "value": what to paste in the value box, "purpose": one sentence on what breaks without it } Give them one record at a time and wait. Three records in one message is where people give up. **The "Name" box is the thing that goes wrong.** Registrars disagree about whether it wants the full record name or only the label. Pasting the full name into a box that wanted a label produces example.com.example.com, which fails silently — no error at the registrar, nothing visibly wrong, and a domain that never works. So: offer "label" if it is present, and tell them that if their registrar rejects it or nothing happens, to use "name" instead. "@" means the domain itself; it is not a typo and it is not something they type literally anywhere else. **A CNAME on the domain itself is often refused.** If "label" is "@", say so before they try: many registrars will not accept a plain CNAME there, because it is invalid DNS. The same thing under another name usually is accepted — ALIAS, ANAME, or "CNAME flattening" — and which one exists depends on the registrar. If none of them do, the answer is to connect the www version of the domain instead, which always works. **www is handled, but it still needs its record.** When a user connects example.com we also register www.example.com and forward it, and vice versa — "alsoCovers" names it and "hostnames" carries its status like any other — a partner with no record shows there as a failed "dns" check with "needsUser": true, which is what tells you the job is not finished. It is not a second domain and does not use a second slot. But it is a separate hostname, so it needs its own CNAME, and that record is already in "records". Do not drop it as redundant: without it, a visitor typing the www version gets a browser security warning rather than the site, because the certificate does not cover a name that never resolved to us. **Then wait, and do not re-add.** DNS takes a few minutes and can take an hour. Poll GET https://dashploy.com/api/v1/projects//domains and read "live", which is true only when every name in the connection has both its record and its certificate — "status" alone is not enough, and neither is the primary hostname on its own. When it is still false, "hostnames[].checks" says which name and which half, and "needsUser" says whether that is yours to fix or ours. Adding the domain again does not speed anything up and will fail as already connected. If it is still not live after an hour, the usual cause is the Name box, so ask them to read back exactly what they entered. A connect that returns NAME_TAKEN means the domain is attached to another account, platform-wide — it is not a spelling problem and it is not retryable. Tell the user to disconnect it wherever it is currently connected. If they are certain nobody else has it, tell them to write to us at https://dashploy.com/contact, or email support@dashploy.com, and stop retrying: the remaining cause is a leftover on our side, which we clear. ### Afterwards GET https://dashploy.com/api/v1/domains Domains this account owns, when they expire, and whether they auto-renew. POST https://dashploy.com/api/v1/domains//auto-renew { "enabled": false } Turning auto-renew off is destructive on a delay. Before doing it, say plainly: on the expiry date the site stops working at that address and the name becomes available for anyone else to register. Getting it back afterwards is often impossible and always expensive. A domain bought here still uses one of the account's custom domain slots. Buying does not raise that limit — check "customDomains" for the plan first, or the connect step will fail after the money has moved. ## MCP — the easier path if your client supports it There is an MCP server at https://dashploy.com/mcp (Streamable HTTP transport, POST only). It gives you typed tools — deploy, get_logs, set_env, add_domain, whoami and the rest — instead of writing HTTP calls. Everything below still works exactly the same; MCP is a wrapper over these same endpoints, with the same errors. Every request to it needs a credential, discovery included. An unauthenticated call returns 401 with a WWW-Authenticate header pointing at https://dashploy.com/.well-known/oauth-protected-resource. There are two ways to hold one. ### 1. OAuth, if your client supports it This is the better path and needs nothing from the user but a click. The server is a full OAuth 2.1 authorization server with dynamic client registration, so a client can discover it, register itself and send the user to sign in: https://dashploy.com/.well-known/oauth-protected-resource resource metadata (RFC 9728) https://dashploy.com/.well-known/oauth-authorization-server server metadata (RFC 8414) https://dashploy.com/oauth/register registration (RFC 7591) https://dashploy.com/oauth/authorize authorization code + PKCE https://dashploy.com/oauth/token token and refresh https://dashploy.com/oauth/revoke revocation (RFC 7009) PKCE with S256 is REQUIRED — a request without code_challenge is refused. The only scope is "dashploy". Access tokens last 24 hours and you are expected to refresh them without involving the user; refresh tokens last 90 days and ROTATE, so store the new one each time. Presenting a consumed refresh token revokes every token for that application, because reuse is what a stolen token looks like. Do not ask the user for a code when this path is available. Nothing is copied and nothing can be pasted into the wrong place. ### 2. A pasted token For clients with no OAuth support. The user's code is at https://dashploy.com/app/tokens: { "mcpServers": { "dashploy": { "type": "http", "url": "https://dashploy.com/mcp", "headers": { "Authorization": "Bearer dp_xxxxxxxx" } } } } If the client is a hosted "connector" rather than a config file, it may RESERVE the Authorization header for its own sign-in and silently drop the one you set. Send the token as X-API-Key instead — the bare token, no "Bearer" prefix: X-API-Key: dp_xxxxxxxx Both headers are accepted everywhere, on MCP and on the REST API alike. Use Authorization unless the client will not let you. ### Either way, prove it CALL whoami ONCE after connecting. It is the cheapest call that proves the credential actually arrives, and connecting is the moment a wrong one is cheapest to fix. If it returns UNAUTHENTICATED, the header is being dropped by the client — switch to X-API-Key and call it again. If it returns an expired token, refresh it and retry before telling the user anything: that fixes itself and does not need them. The user can see and withdraw connected applications at https://dashploy.com/app/connections. Access withdrawn there stops working immediately, including refresh. If you cannot add an MCP server, use the REST API below. It is not a lesser path — it is the same platform. ## Deploy a site POST https://dashploy.com/api/v1/deploy Content-Type: application/json { "project": "my-site", "files": [ { "path": "index.html", "content": "

Hello

" }, { "path": "style.css", "content": "body { font-family: system-ui }" } ] } Rules: - "project" becomes the subdomain. Lowercase letters, numbers, hyphens; 3-63 characters. It is created automatically on first deploy. - "path" is relative to the site root. "index.html" is required at the root. - "content" is UTF-8 text by default. For binary files (images, fonts, PDFs, video) set "encoding": "base64" and base64-encode the content. - Text files must be valid UTF-8, whichever way you send them. A .html, .css, .js, .json, .txt or .md file that is not UTF-8 — UTF-16 is the one that happens by accident, from "Save as Unicode" on Windows — is refused naming the file, because every page here is served as charset=utf-8 and a file in another encoding renders as mojibake for visitors. Re-save it as UTF-8; do NOT reach for "encoding": "base64" to get it through, as that changes only how the bytes travel and the same refusal applies. - Deploying again to the same project replaces the site. There is no separate update call. - File count and size limits depend on the user's plan (Free allows 500 files at 10 MB each; higher plans allow more). A single request may never exceed 60 MB. Exceeding a limit returns PROJECT_TOO_LARGE naming the exact ceiling — do not guess at the numbers, deploy and read the error, or call whoami first. Success returns: { "ok": true, "url": "https://my-site.dashploy.app", "deploymentId": "…", "fileCount": 2, "warnings": [] } Give the user the "url" value. The site is live immediately. The same endpoint deploys a server-side app — send source instead of HTML and the type is detected automatically: { "project": "my-api", "files": [ { "path": "src/index.ts", "content": "import { Hono } from 'hono'; const app = new Hono(); app.get('/', (c) => c.json({ ok: true })); export default app;" } ] } Apps need no separate step: signing in grants what they require, so a normal account can deploy one from the minute it exists. An account an operator has restricted is refused with QUOTA_EXCEEDED and a hint saying so, and static sites keep working. Read "canDeployApps" from whoami rather than assuming either way. ## What can be deployed today Two kinds of project. Detection is automatic — you do not choose. ### 1. Static sites HTML, CSS, images, fonts, and client-side JavaScript with no restriction: it runs in the visitor's browser, so no CPU limit or timeout applies. Frameworks (React, Vue, Svelte, Astro) work — run the production build and send the CONTENTS of the output directory (dist/, build/, out/), not the source. Requires index.html at the project root. Client-side routing works: unknown extensionless paths fall back to index.html. ### 2. Server-side apps (JavaScript / TypeScript) Code that runs on the server when a request arrives. Export a default object with a fetch handler: // src/index.ts export default { async fetch(request, env, ctx) { return Response.json({ hello: "world" }); } }; Or use Hono, which is available: import { Hono } from "hono"; const app = new Hono(); app.get("/", (c) => c.json({ ok: true })); app.post("/items", async (c) => c.json(await c.req.json())); export default app; TypeScript is compiled for you. No build step, no bundler config — send the source. Entrypoint is src/index.ts, src/index.js, index.ts, or index.js. Apps handle every HTTP method, so forms and JSON APIs work. ## Packages available to apps Dependencies are NOT installed from npm. A curated set is available; importing anything else fails with a clear error naming the alternatives. hono web framework and router itty-router minimal router zod schema validation @neondatabase/serverless Postgres over HTTP @upstash/redis Redis over HTTP jose JWT signing and verification bcryptjs password hashing (pure JS) nanoid, uuid unique ids marked Markdown to HTML date-fns dates ms duration parsing Node built-ins available: buffer, crypto, events, path, querystring, stream, string_decoder, url, util, assert. NOT available, and no workaround exists: child_process, raw TCP sockets, worker_threads, cluster, persistent filesystem writes, and native packages (sharp, canvas, puppeteer, bcrypt, better-sqlite3, pg). The runtime is a V8 isolate, not a Node process. Do not try to install a package. If a task needs one that is unavailable, either write the logic directly — most small utilities are a few lines — or tell the user plainly. ## Secrets and environment variables Apps read configuration from the env argument: export default { async fetch(request, env) { const key = env.MY_API_KEY; // set via the API, never in code } }; Set them: PUT https://dashploy.com/api/v1/projects//env { "vars": { "MY_API_KEY": "sk-...", "DATABASE_URL": "postgres://..." } } Values are encrypted, write-only, and never returned — you can list key names and delete keys, but not read values back. **A deploy is required for changes to take effect**, because bindings are baked in at deploy time. NEVER put a secret in the source you send. App code is not public, but static files are, and mixing the two up exposes the key. Always use env. STORAGE is a reserved name — it is the storage API below — and names beginning with CF_, CLOUDFLARE_, DASHPLOY_ or __BD_ are refused. ## File storage for apps — env.STORAGE Apps get object storage at env.STORAGE. Use it for anything a user uploads and for anything the app generates, because the filesystem does not persist between requests: export default { async fetch(request, env) { if (request.method === "POST") { const form = await request.formData(); const photo = form.get("photo"); await env.STORAGE.put("public/car-1.jpg", photo.stream(), { contentType: photo.type }); return Response.redirect(new URL("/", request.url), 303); } return new Response('', { headers: { "content-type": "text/html" } }); } }; Four methods: await env.STORAGE.put(key, body, { contentType }) -> { key, size } await env.STORAGE.get(key) -> Response, or null await env.STORAGE.delete(key) await env.STORAGE.list({ prefix, limit }) -> { keys, truncated } get returns a Response, so you can return it straight to the browser or call .text(), .json() or .arrayBuffer() on it. It returns null when nothing is stored at that key — it does not throw. **Anything stored under "public/" is served directly at https://.dashploy.app/_storage/, without the "public/" part.** That is how to show an uploaded image: store "public/car-1.jpg", link to "/_storage/car-1.jpg". Serving it through your own fetch handler instead costs CPU and a subrequest on every image, for a file the edge would otherwise cache. Keys NOT under "public/" are readable only by your own code. **Read these two numbers before promising a user anything about capacity.** The plan table below is about the ACCOUNT and about DEPLOYS; a running app gets less: per-project storage 50 MB free, 500 MB Starter, 2.5 GB Pro env.STORAGE writes/mo 5k free, 50k Starter, 250k Pro The table's "storage" column is the total across ALL of an account's projects, and one project may use at most the per-project figure above. The table's "writes/mo" column is the DEPLOY budget; a running app may spend 25% of it through env.STORAGE, which is the second row above. Quoting the table figure for either would overstate what the app actually gets. A deployed site and its uploads share one allowance — storing 100 MB of photos leaves that much less for the site itself, and a deploy is REFUSED with QUOTA_EXCEEDED when the two together would cross the limit. If that happens on a project whose uploads dominate, do not try to shrink the deploy: the error names both figures, and the only thing that frees space is deleting uploads through the app. Say so plainly rather than optimising code that is not the problem. whoami reports both, so you can check before promising a user anything. Every storage failure THROWS. Wrap uploads in try/catch; the Error carries "code" and "hint", and the hint states the fix: STORAGE_TYPE_DENIED the key is under public/ with a type that cannot be published. Not retryable — rename, move it out of public/, or tell the user. STORAGE_QUOTA out of bytes, out of writes for the month, or the object is over the per-file limit. Not retryable. STORAGE_BAD_KEY unusable key: empty, ends in a slash, contains ".." or control characters, or over 400 characters. STORAGE_NOT_FOUND only from a direct read; get() returns null instead. STORAGE_DENIED the project was deleted, or its credential is stale. Deploy again. Not retryable in the request. STORAGE_UNAVAILABLE platform fault. Retryable — try once, then tell the user it is a problem on our side. There is no image processing on this tier. Resize on the client before uploading (draw to a canvas, then canvas.toBlob) — it saves the upload as well. ### What may be stored **Under "public/" the file type is restricted**, because those files get a public URL on a dashploy.app subdomain and a link there has to be safe to click. Allowed: images (jpg, png, gif, webp, avif, heic, svg, ico, bmp, tiff), audio and video (mp3, wav, ogg, m4a, aac, flac, mp4, webm, mov), documents (pdf, txt, md, csv, json, xml, rtf, doc/docx, xls/xlsx, ppt/pptx, odt/ods/odp), web assets (html, css, js, map, webmanifest, vtt, srt), fonts (woff, woff2, ttf, otf) and zip. A public file must also have an extension — that is what decides the content type it is served with. Anything else returns STORAGE_TYPE_DENIED. Do not retry it; either store the file outside "public/", or tell the user that type cannot be published. Ask for a type the app genuinely needs at https://dashploy.com/contact. **Outside "public/" there is no type restriction at all.** Private keys are the app's own state — "orders/abc123" with no extension is a normal thing to store, and nothing there is ever served to anyone. ### If the app accepts uploads from its visitors, validate them The type allowlist is the ONLY check the platform performs. It does not inspect file contents and nothing scans for malware. So an app whose upload form is open to the public is a file host, and its reputation is the user's problem. When you write one, unless the user says otherwise: - accept only the types the feature needs — check the extension AND the declared type, e.g. an image feature takes .jpg .png .webp .avif and nothing else - put uploads under "public/" ONLY if they are meant to be world-readable. Anything else stays private and is served through your own handler after you have checked who is asking - never build a key from user input without stripping it first. A filename from a browser is attacker-controlled; store under an id you generate (nanoid is available) and keep the original name in your database - require the visitor to be signed in, or rate-limit by IP, before writing Public uploads are served with a sandboxing Content-Security-Policy, so an uploaded SVG or HTML file cannot run script against the site. Do not rely on that alone — it stops script, not a convincing fake invoice. ## Choosing between the two Use a static site when everything can run in the browser. It is faster, cheaper for the user, and has no CPU limit. Use an app when the task genuinely needs a server: hiding an API key, talking to a database, accepting uploads, webhooks, or server-side logic a browser cannot be trusted with. **An app serves no deployed files.** Only the code is uploaded — the HTML, CSS, images and fonts you send with a static site are not stored for an app, and there is no path that serves them. An app must return every byte from its fetch handler, read it from env.STORAGE, or fetch it from an allowlisted host. Inlining a few KB of CSS into a template string is fine; inlining images is not, and will eventually fail the deploy with PROJECT_TOO_LARGE. For a site that is mostly pages and pictures with a little server logic, deploy TWO projects — a static site for the assets and an app for the API — or put the images in env.STORAGE under "public/". If the user needs stored data: - per-visitor only -> static site with localStorage or IndexedDB - files and uploads -> an app with env.STORAGE - rows, queries, joins -> an app plus @neondatabase/serverless (Postgres over HTTP), connection string in env - sessions, counters -> an app plus @upstash/redis ## Limits Per request, an app gets a CPU ceiling set by the user's plan (10ms free, 50ms Starter, 200ms Pro). Exceeding it returns 503. Keep per-request work small; move anything heavy to the client. There are NO scheduled triggers on this tier. A "scheduled" or cron handler will never be called — do not write one and do not tell the user their job will run. Recurring work has to happen on a request: check a stored timestamp, do the work if it is due, and store the new one. Anything stricter needs an external caller hitting the app's own URL. Apps may only call allowlisted external hosts. A blocked call returns 403 with "error": "egress_blocked" and the host that was refused. This is the COMPLETE list — anything not on it is denied, so do not write code that calls a host you cannot see here: api.openai.com api.anthropic.com api.stripe.com api.github.com api.resend.com *.supabase.co *.upstash.io *.neon.tech cdn.jsdelivr.net unpkg.com cdnjs.cloudflare.com esm.sh fonts.googleapis.com fonts.gstatic.com Only ports 80 and 443. Raw TCP is unavailable, so SMTP and direct database wire protocols cannot work — use an HTTP API. Maps, geocoding, SMS and analytics providers are NOT reachable; say so rather than writing code that calls them. Ask for a host at https://dashploy.com/contact. Monthly quotas apply per plan. Exceeding them stops the site with a 503 until the period resets or the user upgrades. plan monthly yearly websites storage requests/modeploys/day writes/mo domains free free - 2 100 MB 100k 10 20k 1 starter $4.99/mo $49/yr 5 1 GB 1M 50 200k 2 pro $14.99/mo $149/yr 10 5 GB 10M 200 1M 5 UNVERIFIED - - 1 10 MB 10k 3 2k 5 The last row is not a plan. It is what a PRO account gets while its verification level is "none" — the mask replaces some limits and leaves the rest at the plan's own figure, which is why domains there still reads as Pro's. Never quote a plan row to a user without checking "verification" first. "requests/mo" counts every HTTP request, not page views. One page view is the document plus each API call, image and font it fetches, so a typical app spends several requests per visitor. Tell the user requests when you quote this figure; calling them visits overstates what the plan delivers by roughly that factor. A separate BURST ceiling applies PER VISITOR rather than per site: 300 requests per 10 seconds on free, 600 on Starter, 1500 on Pro. A visitor who exceeds it gets 429 for a few seconds. Other visitors are unaffected, the site is not stopped, and nothing needs to be fixed on it. Quote it as a WINDOW, never as a per-second rate. Dividing it is arithmetically fine and describes it wrongly: one screen of a framework app is dozens of requests arriving at once, so "30 a second" sounds like a limit a single page load would breach, when the window comfortably permits several. A normal person browsing does not reach this, so a 429 usually means something automated — a scraper, a monitor, or a loop in the app itself. Upgrading is rarely the answer; ask what was making the requests before suggesting it. Server-side apps require a VERIFIED account on every plan, including paid ones — verification is about proving a person is real, not about paying. Signing in grants it, so a normal account has it from the moment it exists and there is nothing for the user to do. Read "canDeployApps" from whoami rather than assuming either way: an account can still be restricted by an operator, and telling a user to go and verify when they already are wastes their time. Both billing periods are real and purchasable — the user chooses at checkout, and paying yearly is cheaper per month. Quote whichever they ask about, or both. Use these numbers when telling the user what upgrading would change. Do not quote them from memory; they are generated from what the platform enforces, so what is written above is what will actually happen. ## Errors — read this section carefully EVERY failed request returns JSON with the same shape, whatever the status code. Always read the body. A non-200 status is never an unexplained failure here. { "ok": false, "errors": [ { "phase": "auth" | "upload" | "detect" | "bundle" | "deploy" | "boot" | "runtime", "code": "NO_ENTRYPOINT", "message": "No index.html at the project root.", "hint": "A static site needs index.html at the top level...", "location": { "file": "src/server.js", "line": 12 }, "retryable": true } ] } Status codes and what they mean: 400 Something about the request or the code is wrong. Read "code" and fix it. 401 Token problem. Missing, revoked, or wrong. NOT a network failure. 403 Allowed to authenticate, not allowed to do this. Quota, suspension, or an unverified account. 404 Project does not exist, or belongs to someone else. 409 Name already taken by another account. 500 Platform fault, not your code. Retry once, then tell the user. DO NOT report "could not connect", "the server is unreachable", or "something went wrong" for any of these. The body always says what happened and what to do. A connection failure means no response at all — not a 401. HOW TO HANDLE AN ERROR: 1. Read "code" and "hint". The hint states the specific fix. 2. If "retryable" is true, apply the fix yourself and call deploy again. Do this silently — do not ask the user to fix it, and do not paste the raw error at them. Fixing and retrying is the expected behaviour. 3. If "retryable" is false, the fix is NOT in the code. Tell the user in one plain sentence what is wrong and what they must do. Do not retry. 4. Never retry the identical payload unchanged. Change something first. ## When something is wrong and you are not sure why Call this first. It is cheap and answers everything in one request: GET https://dashploy.com/api/v1/whoami It returns the plan, verification level, usage against limits, what is currently blocked, and a "whatToDo" list written as instructions to you. Example: { "ok": true, "plan": "free", "verification": "none", "canDeployStaticSites": true, "canDeployApps": false, "canCreateProject": false, "canDeployToday": true, "canStoreNewFiles": true, "projects": { "used": 1, "limit": 1 }, "usage": { "percentOfPlan": 12, "periodResets": "2026-09-01", "deploys": { "usedToday": 1, "limit": 3, "remaining": 2, "resetsAt": "2026-08-22T00:00:00.000Z" }, "fileWrites": { "usedThisMonth": 180, "limit": 2000, "remaining": 1820 } }, "blocked": ["server-side apps", "creating new projects"], "whatToDo": [ "This account is unverified, so it can deploy static sites but not server-side apps... Tell the user to verify at https://dashploy.com/app." ] } The figures above are an example, not the limits. Read them from the response: they depend on the plan AND on the verification level, and those two do not move together. Relay "whatToDo" to the user in your own words. Those entries are the actual remedy — do not paraphrase them into "there was a problem". ## The situations you will actually hit TOKEN REVOKED OR WRONG (401, INVALID_TOKEN) Say: "Your DashPloy token no longer works — it may have been revoked. Create a new one at https://dashploy.com/app/tokens and paste it here." Then stop and wait. Retrying cannot help. NO TOKEN (401, UNAUTHENTICATED) Ask the user for their token. It is on https://dashploy.com/app, and it was created with their account — they do not have to make one. If they have no account at all, send them to https://dashploy.com/signup. If you have already used the token this session, it is no longer visible to them: ask them to get a NEW one rather than to look up the old one. Do not guess or invent one. ACCOUNT UNVERIFIED (403, QUOTA_EXCEEDED) An unverified account is capped at 1 project, 10 MB, 3 deploys a day and 2k file writes a month, on EVERY plan including the paid ones, and cannot run apps at all. Verifying is free and takes about a minute. Do not suggest upgrading for this. A user already on a paid plan hits the same caps, so telling them to pay is advice that cannot work. Send them to https://dashploy.com/app/verify, then retry. Check "verification" in whoami before you plan a build: if it is "none", say so up front rather than after the fourth deploy fails. FILE WRITE BUDGET SPENT (403, QUOTA_EXCEEDED, "file writes ... remain this month") Storing a NEW file costs one write. A file whose content has not changed since the last deploy costs nothing — content is deduplicated by hash. So this is almost never about deploying too often. It is about sending changed content that did not need to change. The usual causes: - a build step that puts a fresh hash in every filename each run - a timestamp, build id or version string rendered into every page - regenerating the whole site when one page changed Fix the cause and redeploy: send the files that actually differ. Waiting does not help before the 1st, and neither does upgrading if the deploy is rewriting the whole site every time — it will spend the larger budget too. whoami reports usage.fileWrites.remaining. Read it before a large deploy. DAILY DEPLOY LIMIT REACHED (403, QUOTA_EXCEEDED, "Daily deploy limit reached") You have spent today's deploys. Retrying will fail identically, so do not. whoami reports usage.deploys.remaining before you start — read it, and if it is low, finish the whole change locally and deploy ONCE rather than deploying each file as you write it. The remedy depends on why the cap is low: if verification is "none", it is the verification cap and the fix is https://dashploy.com/app/verify. Otherwise it is the plan, and https://dashploy.com/app/billing raises it. Either way the count resets at 00:00 UTC, which is in usage.deploys.resetsAt. Do not tell the user their files are "saved locally and ready" as though that were a deploy. Nothing is published until a deploy succeeds; say plainly that the site has not changed yet. OVER QUOTA (403 on deploy, or the live site returning 503) The site has used its monthly allowance. Deploying new code does NOT bring it back. Tell the user it resets on the date in whoami, or they can upgrade. PROJECT LIMIT REACHED (403, QUOTA_EXCEEDED) Deploying to an EXISTING project still works. Only new projects are blocked. Suggest reusing a project or deleting an unused one. UNSUPPORTED CODE (400, UNSUPPORTED_RUNTIME_API) The code uses something this runtime does not have. The hint names the replacement. Fix it and redeploy — this one IS your job. PACKAGE NOT AVAILABLE (400, MISSING_DEPENDENCY) Dependencies are not installed from npm. The hint lists what IS available. Rewrite using one of those, or write the logic directly. A DEPLOYED APP IS THROWING (the site returns 500) The deploy succeeded, so the fault is at runtime. Call GET /api/v1/projects//logs to see the actual exception, then fix the code and redeploy. Do not guess — the log names the error. Reporting a site somebody else published If a user shows you a site on this platform that is phishing, malware or otherwise harmful, send them to https://dashploy.com/report-abuse. There is no API for this on purpose: a report is read by a person, and it is the fastest control this platform has. SITE SUSPENDED (403, SUSPENDED) Moderation or platform action on a site that WAS live. Nothing in the code will fix it. Tell the user to write to us at https://dashploy.com/contact and wait for a reply. A DEPLOY WAS REFUSED BY CONTENT REVIEW (400, CONTENT_REFUSED) Different from the above, and do not report it as a suspension: nothing was published, nothing was stopped, any existing site is still serving, and no quota was spent. An automated check looks for pages that imitate a well-known company or collect passwords, and it declined to publish this. Do NOT retry — the same content is refused every time. Do NOT try to guess which page caused it and rewrite the user's site: you cannot see the score, and editing a site that is not the problem is worse than doing nothing. Say that a person can review it, and that they should email support@dashploy.com naming the project. Then stop. ## Common error codes Every code the API can return. Each has a page a PERSON can read at https://dashploy.com/docs#error-codes — relay that link when you report a failure you cannot fix on their behalf. EMPTY_PROJECT No files were sent, so there was nothing to publish. NO_ENTRYPOINT The files arrived but nothing said which page to serve first. NO_BUILD_SCRIPT The project looks like it needs building, and no build command was found. INVALID_PACKAGE_JSON The package.json could not be read. PROJECT_TOO_LARGE The site is over the file count or size your plan allows. MISSING_DEPENDENCY The code uses a package that is not available here, or a file that was not sent. DEPENDENCY_INSTALL_FAILEDA package could not be prepared. LOCKFILE_MISMATCH The versions the project asks for do not match the ones available. UNSUPPORTED_RUNTIME_API The code uses something the environment your site runs in does not have. NODE_VERSION_MISMATCH The project asks for a version of Node we do not run. BUNDLE_FAILED The code could not be assembled into something publishable. TYPE_ERROR The code did not pass its own checks. SYNTAX_ERROR There is a mistake in the code, and we can name the file and line. MISSING_ENV The app expects a secret or setting that has not been given to it. CPU_LIMIT_EXCEEDED A single request did too much work and was stopped. MEMORY_LIMIT_EXCEEDED A request tried to hold too much in memory at once. BOOT_TIMEOUT The app took too long to start. UNCAUGHT_EXCEPTION The app stopped with an error while answering a request. UNAUTHENTICATED No code was sent, so we do not know whose account this is. INVALID_TOKEN The code sent is wrong, was replaced, or has been revoked. NOT_FOUND The website or version named does not exist on your account. NAME_TAKEN That address is already in use — by one of your sites, or by somebody else’s. INVALID_NAME That name cannot be used as a web address. DELETE_INCOMPLETE A deletion stopped partway, so the site may still be reachable. EGRESS_BLOCKED Your app tried to reach an address it is not allowed to reach. QUOTA_EXCEEDED You have reached one of your plan’s limits for this period. SUSPENDED This website, or the account, has been stopped. CONTENT_REFUSED An automated check would not publish this content, so nothing was published. UNKNOWN Something failed and we could not identify it precisely. ## Other endpoints GET https://dashploy.com/api/v1/whoami Confirms the token works. Cheap. Call this first if unsure. GET https://dashploy.com/api/v1/projects Lists the user's projects and their URLs. GET https://dashploy.com/api/v1/projects/available?name= Is this name free? Returns {"available": true|false} and, when it is not, a "reason" to relay. Call this before offering a name to the user, not after. See "Naming a website" above. GET https://dashploy.com/api/v1/projects//deployments Recent deployments with status and any stored errors. Each carries an "id" that the restore call below takes. POST https://dashploy.com/api/v1/projects//restore { "deployment": "" } Put an earlier version back. Publishing never deletes anything, so every version listed above is still intact and this only moves a pointer — which means restoring is itself reversible. Use it when a deploy broke a working site, or when one assistant published over another's work. POST https://dashploy.com/api/v1/projects//domains Connect the user's own domain: { "hostname": "www.example.com" }. Returns a "setup" object listing the exact DNS records the user must add. Relay those to the user verbatim — they have to add them at their registrar and getting a value wrong means the domain silently never activates. Allowances differ by plan — the table below has the numbers, and whoami reports what is left. Activation takes a few minutes after the records are live; the site keeps working on its .dashploy.app address throughout. The user can also do this themselves on the project page in their dashboard, which is often easier than reading a DNS record aloud. GET https://dashploy.com/api/v1/projects//domains Lists connected domains. "live": true means the WHOLE connection is serving — the name the user gave you and the www-or-apex partner we registered beside it. It is the field to poll and the field to report. "hostnames" breaks that down, one entry per name, each with "checks": { "check": "dns" | "certificate", "ok": true | false, "needsUser": true | false, "detail": "No DNS record found yet" } "needsUser" is the one to branch on. False means it is happening on its own and the only correct advice is to wait — say so and stop. True means nothing will change until the user acts, so relay "detail" and the matching record from "records". Getting that backwards either nags somebody about work they cannot do or leaves them waiting for something that will never happen. Read this before telling anybody their domain is fine. "live" on the connection can be false while the name they typed is perfectly healthy, because its www partner has no record yet — that is the usual case, and "hostnames" is what says which half. DELETE https://dashploy.com/api/v1/projects//domains/ Disconnect a domain. Use it to undo a hostname added by mistake, or to free a slot when the allowance is full. The site keeps its .dashploy.app address. This does NOT remove the records at the registrar — tell the user to delete those themselves, or the domain keeps pointing at nothing. GET https://dashploy.com/api/v1/projects//logs Runtime logs from a deployed app: console output and uncaught exceptions. Optional: ?level=error to see only failures, ?since=. Returns a "summary" field first — a one-line diagnosis with repeated errors grouped. Read that before the full list. USE THIS when a deployed app misbehaves. Build errors are returned by the deploy call; this is the only way to see why an app that DEPLOYED SUCCESSFULLY is throwing at runtime. Fix the code and redeploy, exactly as you would for a build error. Static sites produce no logs — they run no server-side code. An empty result says which case it is. GET https://dashploy.com/api/v1/projects//env Lists environment variable NAMES. Never returns values. PUT https://dashploy.com/api/v1/projects//env Sets variables: { "vars": { "KEY": "value" } }. Redeploy to apply. DELETE https://dashploy.com/api/v1/projects//env/ Removes one variable. Redeploy to apply. POST https://dashploy.com/api/v1/projects//rename { "name": "" } Moves the site to a new address. The old one stops working immediately and is released for anyone to claim, so confirm with the user first. Static sites only — a project running server-side code is refused with an error saying what to do instead. Read out any "warnings" in the response. DELETE https://dashploy.com/api/v1/projects/ Deletes the project, its files, and its subdomain. Not reversible. If this returns "DELETE_INCOMPLETE" the deletion stopped partway. Do not report it as finished. Read the "hint": it says which half happened — either nothing was removed and the site is still live, or the site has stopped but its record survives, so the user still sees it listed while the address returns 404. When "retryable" is true, call this again; repeating it is safe and is what finishes the job. When it is false, storage is down on our side: tell the user and stop. ## Full example curl -X POST https://dashploy.com/api/v1/deploy \ -H "Authorization: Bearer $DASHPLOY_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "project": "my-site", "files": [ { "path": "index.html", "content": "Hi

Hello

" } ] }'