REST API
Publish from your own code
One authenticated PUT takes a file and gives back a live URL. Everything else — gating it, sending a named link, reading who opened it — is one more call. This page covers how the pieces fit and what the failures mean; the exhaustive endpoint reference is generated from the running service and lives at https://api.quickhost.ing/docs.
The model
What am I actually working with?
Four things, nested. A workspace is the tenant and holds the plan and the people; folders group projects inside it; a project owns a slug and everything about who may see it; and each publish adds a version. You address projects by slug and almost never touch the rest.
- Workspace
- The tenant. Carries the plan, the quotas and the members.
- Folder
- Grouping inside a workspace. Private to you, or shared with the team.
- Project
- One deliverable, one slug, one subdomain. Visibility, password, expiry and comments all live here.
- Version
- What a publish creates. Old ones are pruned by plan; the slug always serves the newest.
Storage is keyed by immutable IDs rather than by slug, so renaming a project is a database change and nothing moves. The old URL stops resolving the moment you rename it, which is the point — but it also means a link you already sent is gone, so rename before you send, not after.
Auth
How do I authenticate?
With a token from Settings → API tokens, sent as Authorization: Bearer qh_…. It is shown once. There are no scopes yet, so it can do anything your account can — publish, change who may see a project, and read your clients’ comments. Treat it like a password.
export QH_TOKEN="qh_…"
curl "https://api.quickhost.ing/v1/projects" -H "Authorization: Bearer $QH_TOKEN"The qh CLI does not use one of these. It runs a device flow — you approve it in the browser and it stores its own credential — so a long-lived token never has to be pasted into a shell, a dotfile or a history file. If you are automating your own machine, prefer the CLI for that reason.
The job
How do I publish something?
Three calls cover almost every integration: put the file somewhere, find out the doorcode, send a link you can track. Each response below was captured from a running server rather than written out from the schema.
Publish one file
The whole job in one call. PUT creates the project if the slug is free and adds a version if it is yours, so a republish is the same command as the first publish — no create-then-upload dance, and no multipart.
curl -X PUT "https://api.quickhost.ing/v1/projects/acme-proposal?visibility=password" \
-H "Authorization: Bearer $QH_TOKEN" \
-H "Content-Type: text/html" \
--data-binary @proposal.htmlResponse
{
"slug": "acme-proposal",
"url": "https://acme-proposal.quickhost.ing",
"kind": "site",
"version": 1
}Read the doorcode back
A project password is a doorcode you hand a client, not a credential, so it is stored readable and returned to the owner. You can look it up weeks later and re-send it instead of resetting it and breaking the link you already sent.
curl "https://api.quickhost.ing/v1/projects/acme-proposal" \
-H "Authorization: Bearer $QH_TOKEN"Response
{
"slug": "acme-proposal",
"visibility": "password",
"password": "c4jg7c9byj9q",
"url": "https://acme-proposal.quickhost.ing"
}Access
How do I control who sees it?
With visibility, which is public, password or private. Set it when you publish, or change it later with a PATCH — the switch takes effect immediately rather than after a cache sweep.
public
Anyone with the URL. Still noindex by default, so it will not turn up in search.
password
A shared doorcode. If you do not supply one it is generated, because a gate with no password is one nobody can pass.
private
Only workspace members, signed in. Share links are the way out of this.
Every project is noindex unless you say otherwise. A client deliverable that turns up in a search for your client’s name is a problem you would find out about from them, so the default is the safe one and opting in is deliberate.
Analytics
What counts as a view?
A page load. The viewer HTML is the view, and it is always served from the origin so it is always counted. Stylesheets, scripts, fonts, images, and raw assets behind a generated viewer — PDFs, video, CSV — are explicitly not views, which is why opening one PDF is one view rather than however many requests the browser made.
curl "https://api.quickhost.ing/v1/projects/acme-proposal/analytics?days=30" \
-H "Authorization: Bearer $QH_TOKEN"
{
"totals": { "views": 0, "unique_visitors": 0, "last_viewed_at": null },
"series": [ { "date": "2026-08-19", "views": 0, "unique_visitors": 0 } ],
"share_links": [ { "label": "Acme review", "view_count": 0, "first_opened_at": null } ],
"window": { "days": 30, "requested_days": 30, "retention_days": null, "truncated": false }
}Check window before you plot anything. History is kept per plan — 7 days on Starter, 90 on Studio, a year on Agency — and if you ask for more than you keep, truncated comes back true and days tells you what you actually got. The same exclusion set drives edge caching, so the two can never disagree about what a view was.
Failures
What do errors look like?
Always the same envelope: {"error": {"code", "message", "field", "details"}}. Branch on code — it is a stable string, and message is prose that may be reworded without warning. field names the offending input where one exists.
| Code | When | What to do |
|---|---|---|
| auth_required401 | No token, or one that has been revoked or expired. | Send Authorization: Bearer qh_… — a missing header and a bad token give the same answer on purpose, so a probe learns nothing. |
| slug_taken409 | Something already occupies that subdomain. | Ask first with GET /v1/projects/{slug}/available. Slugs are global — a 404 from GET /v1/projects/{slug} only means you cannot see it, which is a different question. |
| slug_reserved409 | The name is held back — brand impersonation, profanity, or infrastructure labels. | Pick another. This list is not published, so treat it as a normal rejection rather than something to probe. |
| email_not_verified403 | The account has never confirmed its email address. | Open the confirmation email. Signing in, browsing and creating projects all work before this — only publishing is gated, so do not render it as a billing problem. |
| payment_required402 | The trial or the subscription ended. | Publishing stops immediately; already-published links keep serving for 30 days. Distinct from quota_exceeded on purpose — this is 'your access lapsed', not 'you are using a lot'. |
| quota_exceeded402 | A plan cap — projects, storage, or members. | Read error.field for which cap. Members is the one that can be raised without changing plan on Studio and Agency. |
| upload_too_large413 | One upload exceeded the per-request cap for the plan. | The stream aborts at the cap, so nothing partial is stored. Split the upload or move up a plan — 25 MB on Starter, 500 MB on Studio, 1 GB on Agency. |
| validation_error422 | The request body or query is malformed. | error.field names the offending field and error.details.errors lists every one of them, so you can fix them in a single pass rather than one round-trip each. |
Reference
Where is the complete endpoint reference?
At https://api.quickhost.ing/docs, and it is generated from the running service rather than maintained beside it — so it cannot describe an endpoint that no longer exists. This page covers what that reference structurally cannot: how the pieces relate and what to do when something fails.
- /docs
- Interactive reference — every endpoint, try-it-out included
- /redoc
- The same content, laid out for reading
- /openapi.json
- The schema, if you would rather generate a client
- /docs.md
- The whole surface in one plain-text file, for an agent
Comments
How do client comments and the allowlist work?
Turn them on per project with
{"comments_enabled": true}. Who may comment is governed by an allowlist that inherits — a project falls back to its folder, and a folder to the workspace — so you set the rule once for a client rather than on every deliverable you send them.inherited_emailsis what you are getting from the level above andemailsis what this level adds. Setcomment_allowlist_inheritto false when a project needs to be narrower than its folder — one deliverable a wider client team should not be able to comment on.