---
title: "Deploy an app"
method: POST
path: "/apps"
tags: ["Apps"]
---

# Deploy an app

`POST /apps`

## Request

- Base URL: `https://api.hypamoon.com/api/v1`
- URL: `https://api.hypamoon.com/api/v1/apps`
- Auth: one of:
  - HTTP bearer
  - OAuth 2 (authorization code, authorization URL `https://app.hypamoon.com/oauth/authorize`, token URL `https://api.hypamoon.com/oauth/token`, scopes: `sites:write`)

An app is a site with server code: requests to /api and everything below it on the site go to your code, all other paths keep serving the site's static files. Same address, no CORS; the site's password, paid access and own domain apply to /api too. The code is one bundled ES module (plus optional extra modules) in the standard module worker format (export default { fetch, scheduled }); no build step runs here. fetch() only reaches allowed_hosts and the Hypamoon API (the project's own data, with a key Hypamoon adds). To use a secret from the vault, send the header x-hypamoon-secret: <name>; the code never sees its value. Each app runs isolated, with limits per request (CPU time, fetch calls) and an outbound budget per minute and per day. The first deploy for a site creates the app (201); a deploy for a site that already has one publishes a new version (200). On a new version, every setting you leave out (allowed_hosts, secrets, schedules, vars, auth, database, fetch_pages) keeps its current value, and the answer lists them in kept. Clear one explicitly: [] or {} for a list or vars, null for auth, false for fetch_pages. database: false on an app that has a database is refused (409): a deploy never disconnects a database. The code works the same way: leave main_module and modules out to keep the running code (to change only settings, or to move a test app to live), or send only the changed modules with keep_existing_modules: true. A new app always needs main_module and modules. The ten newest versions are kept for rollback. Before anything goes live the code is checked for mining, hidden or downloaded code and fake login pages; there is no option to skip that check. A live app needs a verified project. An app that runs in test mode moves to live when you deploy it with a live key once the project is verified: it keeps its database and its users. A live app cannot go back to test (key_mode_mismatch).

## Request body

- object
  - `site_id` string, uuid, required: The site the app belongs to (from deploy_site or list_sites). Requests to /api and everything below it on that site go to the app; all other paths stay the site's static files.
  - `name` string: A name for your own overview. Defaults to the site's name.
  - `main_module` string: The module with export default { async fetch(request, env, ctx) { ... } } (and optionally scheduled). Usually index.js.
  - `modules` object[]: The bundled server code: the main module plus at most 19 more (ES modules, JSON or text). No WebAssembly, no npm install: bundle dependencies into the module.
    - `name` string, required: The module's file name, like index.js, lib/util.js, data.json or template.html.
    - `content` string, required: The module as plain text: bundled JavaScript (an ES module), JSON or text. No build step runs on Hypamoon.
    - `type` 'esm' | 'json' | 'text': Leave it out: it follows from the extension (.js and .mjs are esm, .json is json, .txt, .html, .md, .css, .svg and .csv are text).
  - `allowed_hosts` string[]: Hosts the app may call with fetch(): exact names like api.example.com, or *.example.com for its subdomains. Everything else is blocked. At most 20. The Hypamoon API (for the project's data) is always allowed.
  - `secrets` string[]: Names of secrets from the project's vault (request_secret) the app may use. The code never sees a value: it sends the header x-hypamoon-secret: <name> and Hypamoon puts the secret in the request, only to the hosts of that secret. At most 10.
  - `schedules` object[]: When the app's scheduled(event, env, ctx) handler runs. At most 3.
    - `cron` string, required: Five fields in UTC, like "*/15 * * * *" or "0 7 * * 1-5". Minutes in steps of 5 (0, 5, ... 55): schedules run on a five-minute clock.
  - `vars` object: Plain settings in env, like {"CITY": "Delft"}. Never a key or password: use secrets. At most 20, names in UPPER_CASE.
  - `auth` object, nullable: Sign-in for the people who use the app. They sign in on the site itself (/_hypamoon/auth/login) with a link by email; Hypamoon keeps only their email address and name. Hypamoon checks the sign-in before a request reaches the code and hands the person over ready to use: env.HYPAMOON.user is { id, email, name, roles, entitlements, groups, is(role), has(entitlement) } or null, env.HYPAMOON.requireUser(request) gives the user or a ready 401 JSON Response, and env.HYPAMOON.requireRole(request, role) the user or a ready 401 or 403 (if (user instanceof Response) return user). Never verify the signature of the x-hypamoon-user headers yourself and never use HYPAMOON_AUTH_SECRET in the code; only forward those two headers unchanged to env.HYPAMOON_API (/app-files, /app-users). The first role in roles is the admin role everywhere.
    - `signup` 'invite' | 'open' | 'approval' | 'domain': invite (the default): only people you add with invite_app_user, or who use an invite link, can sign in. open: anyone with an email address can sign up and gets default_role. approval: anyone can ask to join (also as a waitlist); after confirming their email the request waits until an admin approves it on /_hypamoon/admin. domain: only addresses on allowed_domains can sign up.
    - `roles` string[]: The roles in this app, most powerful first, like ["admin", "member"] (the default).
    - `default_role` string: The role of someone who signs up on an open app, and of an invite without a role. Defaults to the last role in roles.
    - `google` boolean: Also offer Continue with Google next to the email link.
    - `protect` 'site' | 'none': site: every page and file of the site needs a signed-in user; visitors who are not signed in get the sign-in page (only the icon and the web app manifest stay open). none: the pages are public and the code decides per request. /api always stays with the code (use env.HYPAMOON.requireUser). Leave it out to keep the current setting; a new app with signup invite starts with site.
    - `session_days` integer: How many days a sign-in lasts after the last visit (1 to 90, default 90; 7 for a sensitive app). Shorter means people sign in again sooner. The admin role has its own, shorter default: see admin_session_days. Leave it out to keep the current setting.
    - `admin_session_days` integer: How many days a sign-in of the admin role (the first role in roles) lasts after the last visit (1 to 90, default 14), never longer than session_days. Whoever can see and change everything signs in again sooner. A change applies from the next sign-in; sessions that are already open run out as they were. Leave it out to keep the current setting.
    - `allowed_domains` string[]: Only with signup domain: the email domains that may sign up, like ["example.com"]. Exact domains, no subdomains.
    - `buyers` boolean: true: someone who buys paid access to this site (create_access_product) becomes a user of the app with default_role and an entitlement for what they bought: env.HYPAMOON.user.has(entitlement). They sign in once, with the app's sign-in. Leave it out to keep the current setting.
    - `groups` boolean: true: the app can put its users in groups (teams), with create_app_group and add_app_group_member or on /_hypamoon/admin. A group can give its members an extra role and an extra entitlement (never the admin role); the code sees env.HYPAMOON.user.groups, inGroup(key), and the extra roles and entitlements in is() and has(). Leave it out to keep the current setting.
  - `database` boolean: true gives the app its own SQL database (SQLite) as env.DB: env.DB.prepare(sql).bind(...).all(). Create and change tables with apply_app_migration. The database stays when you deploy a version without it; delete_app removes it.
  - `fetch_pages` boolean: true turns on env.HYPAMOON.fetchPage(url) for this app (off by default; needs auth). It fetches one public web page for the signed-in user, for example to import a recipe or read a product page from any site, without listing that site in allowed_hosts: const page = await env.HYPAMOON.fetchPage(url) gives { url, status, contentType, body, truncated }, with url the final address after redirects. Only GET, http and https, text, HTML or JSON (also JSON-LD), at most 2 MB (truncated: true when cut off) and 10 seconds, at most 3 redirects. Private addresses and Hypamoon hosts are blocked. No cookies or x-hypamoon headers are sent. Each signed-in user can fetch 20 pages per hour, and the fetches count against the app's outbound budget. A refusal throws an Error with .status: 401 not signed in, 403 blocked address, 415 not text, 429 limit reached, 501 off for this app, 502 or 504 the page failed or was too slow. On an update, leave it out to keep the current setting; false turns it off.
  - `keep_existing_modules` boolean: On an update: true keeps every running module you do not send, and replaces or adds only the modules you send (like keep_existing on deploy_site).

## Response `200`

A new version is live.

- object: An app: a site with server code on /api.
  - `id` string, uuid, required
  - `name` string, required
  - `site_id` string, uuid, required
  - `url` string, required: The site; the app answers on url + /api.
  - `api_url` string, required: Where the app's /api starts.
  - `mode` 'live' | 'test', required
  - `status` 'active' | 'offline', required: offline: Hypamoon took the site offline; the app does not run.
  - `version` integer, required: The version that runs now.
  - `allowed_hosts` string[], required
  - `secrets` string[], required
  - `schedules` object[], required
    - `cron` string, required
  - `limits` object, required: Per request: CPU time and fetch calls. Per app: outbound requests per minute and per day.
    - `cpu_ms` integer, required
    - `subrequests` integer, required
    - `outbound_per_minute` integer, required
    - `outbound_per_day` integer, required
    - `bundle_bytes` integer, required
  - `auth` object, nullable: Sign-in for the app's users; null when it is off. login_url is where people sign in.
    - `signup` 'invite' | 'open' | 'approval' | 'domain', required
    - `roles` string[], required
    - `default_role` string, required
    - `google` boolean, required
    - `protect` 'site' | 'none'
    - `session_days` integer
    - `admin_session_days` integer
    - `allowed_domains` string[]
    - `buyers` boolean
    - `groups` boolean
    - `login_url` string, required
  - `database` object, nullable: The app's own database (env.DB); null when it has none. full: above the plan's data limit, so the app can only read and delete until there is room.
    - `bytes` integer, required
    - `full` boolean, required
    - `per_user_tables` object[]: Tables marked with set_table_owner_column: the app reads and writes them only through env.DB.mine(table), for the signed-in user.
      - `table` string, required
      - `column` string, required
      - `admin_role` string, nullable, required
  - `kept` string[]: Only in the answer to a deploy of an existing app: the settings that were left out and kept from the running version, like "allowed_hosts (20)", "vars (2)", "database", "auth".
  - `warnings` string[]: Only in the answer to a deploy: advice about the app's data, never a block. For example that export and delete per user won't cover the data (no table marked with set_table_owner_column), or tables that look like backups or copies. Tell the user in plain words and offer to fix it.
  - `created_at` string, required
  - `updated_at` string, required

## Other responses

- `201`: The app is live.
- `202`: Four-eyes approval is on: the deploy waits for another owner or builder. The running code has not changed yet.
- `400`: Bad Request: invalid_json.
- `401`: Unauthorized: unauthorized, missing_api_key, invalid_api_key, api_key_revoked, project_deleted.
- `402`: Payment Required: plan_limit_reached.
- `403`: Forbidden: project_not_enabled, key_mode_mismatch, missing_scope, verification_required, site_offline.
- `404`: Not Found: not_found.
- `409`: Conflict: plan_limit_reached, conflict.
- `413`: Error: app_too_large.
- `422`: Unprocessable Content: validation_failed, app_blocked.
- `429`: Too Many Requests: rate_limited, deploy_limit_reached.
- `500`: Internal Server Error: internal_error.
- `503`: Service Unavailable: service_unavailable.

---

[API](https://skmtc.dev/hypamoon/apis/hypamoon-api.md) · [All operations](https://skmtc.dev/hypamoon/apis/hypamoon-api/llms.txt) · [OpenAPI document](https://skmtc.dev/hypamoon/apis/hypamoon-api/revisions/9f0b90004c06?raw)
