Skip to content

Apps Endpoints

Manage registered applications. Apps are uniquely identified by (app_name, environment, branch) — but the environment is not a request parameter: the API process is bound to a single environment and resolves it server-side.

Group scoping

Every route here is group-scoped. Callers see only their own group's apps; a platform operator can target a specific tenant with ?group=<slug>. A cross-tenant lookup returns 404, never 403 — the API never confirms that another tenant's app exists.

Endpoints

Method Path Scope Description
GET /api/apps read List apps in the caller's group scope
GET /api/apps/{app_name}?branch= read Get app details
GET /api/apps/{app_name}/branches read List the branches an app is registered on
GET /api/apps/{app_name}/branch-candidates?provider=&repo= read The repo's branches, each stamped with what registration would do
GET /api/apps/{app_name}/config?branch= read Config in layers (base / overlay / effective / YAML seed)
PUT /api/apps/{app_name}/config?branch= deploy Save a config — as an object or as raw YAML
POST /api/apps deploy Register a new app → 201
PATCH /api/apps/{app_name}?branch= deploy Update app configuration
PATCH /api/apps/{app_name}/primary-services?branch= deploy Set the per-service front-door override
PATCH /api/apps/{app_name}/services/{service_name}/replicas?branch= deploy Set a service's declared replica count and redeploy → 200
DELETE /api/apps/{app_name}?branch= admin Delete an app registration → 204
POST /api/apps/{app_name}/redeploy?branch= deploy Rebuild + deploy (or deploy the latest image) → 202
POST /api/apps/{app_name}/retry deploy Retry a failed deploy's failing phase → 202
POST /api/apps/{app_name}/reconcile?branch= deploy Re-assert the live route against visibility
POST /api/apps/{app_name}/set-production?branch= deploy Promote a branch to the project's production version
POST /api/apps/{app_name}/preflight/recheck?branch= deploy Re-run the git-repo preflight → 202

List Apps

GET /api/apps

Takes no required parameters. Platform operators may add ?group=<slug> to target a specific tenant; an operator without one sees every app in the environment.

Response: 200 OK

{
    "items": [
        {
            "id": "550e8400-e29b-41d4-a716-446655440000",
            "app_name": "my-web-app",
            "display_name": "My Web App",
            "slug": "my-web-app",
            "group_slug": "acme",
            "branch": "main",
            "repository": "git@github.com:org/my-web-app.git",
            "config_path": "appmecca.yaml",
            "deploy_status": "running",
            "visibility": "public",
            "is_production": true,
            "latest_build": "main-abc12345",
            "build_count": 12,
            "running_version": "abc12345",
            "running_build_id": "0d809902-dd9d-4fdf-aa58-48be3b8a4e6a",
            "running_since": "2026-03-15T09:12:00Z",
            "urls": ["https://my-web-app-web-acme.dev.appmecca.net"],
            "database_enabled": false,
            "cache_enabled": false,
            "email_enabled": false,
            "preflight_status": "ready",
            "created_at": "2026-03-10T14:30:00Z",
            "updated_at": "2026-03-15T09:12:00Z"
        }
    ],
    "total": 1,
    "domain": "dev.appmecca.net"
}

domain is the environment's base domain, returned once so clients don't have to derive it. Each item is enriched server-side with urls (the app's live front doors), running_version / running_since (from the last successful container deploy — see the note on Get App for why the list and the single-app route answer different questions, and for why both go null once the app is undeployed), and deploy_warning when a newer deploy failed while an older version keeps serving. ⚠ deploy_warning is suppressed for an undeployed app for the same reason: it is only meaningful while something is still serving.


Get App

GET /api/apps/{app_name}?branch={branch}

Path parameters:

Parameter Type Description
app_name string The app name or slug (sanitized, lowercase)

Query parameters:

Parameter Type Default Description
branch string the production branch Branch name. Omit to resolve the project's production branch.

Response: 200 OK — single AppResponse object (same shape as list items).

The single-app response additionally surfaces failure details so clients never have to join against builds, deployments, or logs to explain a failure:

Field Type When populated
last_deploy_status string \| null Status of the most recent deploy action on this app+branch (running, failed, deploying, etc.).
last_deploy_error string \| null The error recorded for that action. Present on every non-successful deploy.
last_crash_output object \| null {service_name: [line, ...]} with up to 30 lines per service. Only present when the latest deploy is failed AND the agent's health-check phase captured container crash output. Infrastructure-level failures (routing, image pull) leave this null.
last_build_status / last_build_error string \| null Equivalent surfacing for the most recent build.
running_version / running_since string \| null The image tag currently serving, and when it went live. Resolved per branch. ⚠ This route answers "what release does the front door serve?" and so covers both deploy shapes — a type: static app reports its static_deploy_completed tag here. GET /api/apps (the list) answers the narrower "what container is running?" and leaves these null for a static app; the two are deliberately different questions (BUGS #1008). Both include RETRY, so a successful appmecca app retry reports the retried tag, not the one before the failure. ⚠ Both are null once deploy_status is undeployed — an UNDEPLOY does not retract the completed DEPLOY row (it writes its own and flips the app's status), so without this the history row would report a tag for up to the 30-day prune window on an app where nothing is serving (BUGS #1187). The suppression is an exclusion of that one status, never an allow-list: failed and undeploy_failed still report a tag, because both leave containers serving and are exactly when you need to know which. null means not reported — never a guess.
running_build_id string \| null builds.id of the build that produced the running release — the only field that identifies WHICH build is deployed. The image tag is the commit sha and is not build-unique: a rebuild of one commit produces a second build with the same tag, and under a mutable ECR repo the later push overwrites the earlier image, so the tag does not even identify the bytes (BUGS #1096). null means unattributable — a --no-build deploy (no depends_on), a RETRY or drain child (they carry parent_action_id instead), or a release row pruned at 30 days. It is also null whenever running_version is — the three retract together, so a build cannot be badged currently deployed on an undeployed app. Render unknown for null; do not fall back to matching tags.
deploy_warning string \| null Set when a newer deploy failed while an older version keeps serving.
urls string[] The app's live front doors.
services[].url string \| null Per-service address, stamped at read time (list + by-name responses; BUGS #1234) — built by the canonical hostname builder in the same loop as urls, honouring the app's routing shape (path-routed: the front door bare, a mounted service with its mount suffix). null for a service the platform does not route (no port and not static, or unmounted under path routing). Stored per-service url/urls values are never echoed — they are rebuilt on every read (the flat-urls rule of BUGS #6b, applied per service). Absent only on an env with no configured domain.
visibility string public or private.
is_production boolean Whether this branch is the project's production version.

The response also carries the git-repo preflight state — a newly-registered app is quarantined until its repo is proven buildable:

Field Type Notes
preflight_status string One of pending, no_access, needs_config, config_invalid, ready, exempt. Only ready and exempt unlock build + deploy — every other value quarantines the app (build/deploy/retry 409). Defaults to the fail-safe pending.
preflight_result object \| null The structured verdict the dashboard quarantine view and mecca app show render — {reason, detail, services, missing_paths, errors}.

Errors:

  • 404 Not Found — app does not exist for the given name/branch, or belongs to another group.

List Branches

GET /api/apps/{app_name}/branches

Returns every branch the app is registered on. Backs the -b branch selector in mecca var and friends: the CLI expands a glob or comma-list against this list, then issues the per-branch calls.

Response: 200 OK

{
    "app_name": "my-web-app",
    "environment": "dev",
    "branches": ["main", "dev", "feature/dark-theme"]
}

?next_branch=<name> (FEATURES #437) — a branch you are about to register. When sent, the response also carries:

Field Type Meaning
next_branch_would_be_production boolean | null Registering this branch would make it the app's production branch. null = could not be determined.
production_branch string | null The app's production branch today (is_production → main → newest).
next_branch_copy_refusal string | null The sentence to show when nothing may be copied into this branch; null when a copy is allowed.
clone_components_available object {db, drives, buckets} — which data halves the app declares, so a client offers only those.

A registration that copies from another branch is refused when the new branch is or would become production, so a client should ask this first and show next_branch_copy_refusal rather than work it out itself.

⚠ The keys' presence is the feature flag. An API older than FEATURES #437 omits them and silently ignores copy_vars_from_branch on register, so a client must not offer the copy unless these keys come back.


List Branch Candidates

GET /api/apps/{app_name}/branch-candidates?provider=gitlab&repo=group/project

Every branch on the repository (not just the registered ones), each stamped with what registration would do with it. Backs the dashboard's "Deploy a branch" picker, which marks a branch that cannot be registered before the click rather than after the POST.

Session-only — listing branches uses the caller's own linked provider token, so an api-key caller gets 401. provider must be a linked Git provider and repo the repository's full name (owner/name, or a nested GitLab path).

Response: 200 OK

{
    "app_name": "my-web-app",
    "environment": "dev",
    "rows": [
        {
            "name": "main",
            "identity": "main",
            "default": true,
            "protected": true,
            "committed_date": "2026-09-16T09:12:04Z",
            "verdict": {"kind": "deployed"}
        },
        {
            "name": "release/1.0.0",
            "identity": "release-1-0-0",
            "default": false,
            "protected": false,
            "committed_date": null,
            "verdict": {"kind": "collides", "with": "release/1-0-0"}
        },
        {
            "name": "feature/dark-theme",
            "identity": "feature-dark-theme",
            "default": false,
            "protected": false,
            "committed_date": null,
            "verdict": null
        }
    ],
    "total": 3,
    "truncated": false
}

verdict is null when the branch can be registered, or one of:

kind Meaning What registration would return
deployed Already registered on this app — this is the redeploy path —
collides Its derived identity is already taken by the named sibling branch 409
label_too_long The hostname it would need exceeds max characters 422

Predict, never enforce

The verdict is a courtesy, not a gate. It is computed by the same predicates the registration guards refuse on, but it can be stale — a sibling branch registered in another tab between this call and the POST. Clients must keep handling the 409 and 422 exactly as before.

Pagination

Branches are fetched to a cap of 500. total is the provider's count where it reports one, and truncated is true when the cap was reached — surface both rather than presenting a partial list as complete.

committed_date is GitLab-only; GitHub does not return a commit date on a branch row, so it is null there. Do not sort by it as a primary key.


Register App

POST /api/apps

Scope: deploy or higher.

Request body:

{
    "app_name": "My Web App",
    "repository": "git@github.com:org/my-web-app.git",
    "branch": "main",
    "description": "Frontend + backend web application",
    "config_path": "appmecca.yaml",
    "database_enabled": false,
    "cache_enabled": false
}
Field Type Required Default Description
app_name string yes — Display name, 1–128 chars (sanitized to slug internally)
repository string yes — Git repository URL
display_name string no — Explicit display name
branch string no main Branch to track
description string no — Human-readable description
config_path string no appmecca.yaml Path to appmecca.yaml in repo
group_slug string no caller's scope Register under an explicit group (alias: group). Goes through the same membership check as ?group=.
git_clone_provider string no — Clone with the caller's linked provider token (private-repo picker)
deploy_config object no — Deployment configuration
services array no — Service definitions
containers array no — Container definitions
database_enabled boolean no false Whether the app needs a database
cache_enabled boolean no false Whether the app needs a cache
email_enabled boolean no false Whether the app may send email through the relay
copy_vars_from_branch string no — Copy env vars from this already-registered branch of the same app inside the registration, before the first build. Existing keys are never overwritten. 409 when the new branch is or would become the production branch (see List Branches ?next_branch=); 404 when the source is not a branch of this app in this group; 422 when the app has no other branch. The response carries copied_var_keys (key names only).
clone_data_from_branch string no — Restore this branch's data from another registered branch on its first deploy (a fresh backup of the source is taken then). Same 404/409 refusals as copy_vars_from_branch; 422 when combined with seed_from_reference; 503 until every deployment agent can run the copy. The response carries clone_data_scheduled: {source_branch, components}.
clone_components object no {db: true, drives: false, buckets: false} field defaults; db + drives when omitted entirely {db, drives, buckets} for clone_data_from_branch.
seed_from_reference string no — One-shot seed source <app>:<branch>:<selector> (max 512), consumed on the first deploy. The source must be this app or an app in the same group: otherwise 404 (one message for not-found, other-group and ambiguous). Unparseable → 422.

There is no environment field — the API resolves its own environment.

Behavior:

  • The app_name is sanitized to a URL-safe slug (lowercase, hyphens).
  • A unique slug is generated from the display name — if a collision exists, a numeric suffix is appended (e.g. my-app-2).
  • The authenticated user's username is recorded as created_by.
  • The enablement flags (database_enabled, cache_enabled, email_enabled) are derived from deploy_config when the caller omits them, and are re-derived from the repo's appmecca.yaml on every deploy — so they self-heal.
  • A new app is quarantined at preflight_status: "pending" until the git-repo preflight proves the repo buildable.

Response: 201 Created — the newly created AppResponse.

Errors:

  • 403 Forbidden — the caller is not authorised for the requested group_slug.
  • 409 Conflict — an app with this name/branch already exists; the branch collides with an existing one after sanitisation; or this repository is already registered in this group under a different app name (BUGS #1237). The last case fires only when creating a net-new (group, app_name) whose (repository, config_path) already belongs to another app in the same group — adding a branch of an existing app, and registering a second app from the same repo with its own config_path (a monorepo), are both unaffected. The detail names the existing app and both remedies. Comparison is normalised (scp/https/ssh:// forms, trailing /, .git; config_path NULL ≡ appmecca.yaml, ./-prefix and trailing / folded, case-sensitive — git paths are), group-scoped (the same repo registered by two tenants is legitimate), and skipped entirely when the repository cannot be parsed.
  • 409 Conflict — group_renamed_during_registration: the group's slug changed while the app's name was being derived; detail.group_slug is the current slug — retry against it. Also group_deleted_during_registration (retry), or group_busy (another change to the group holds its lock; retry in a few seconds).
  • 404 Not Found — the group was deleted while the request waited for its lock; nothing was written.
  • 422 Unprocessable Entity — the repository URL is not on a supported git host.

Update App

PATCH /api/apps/{app_name}?branch={branch}

Scope: deploy or higher.

Request body: partial update — only include fields to change.

{
    "description": "Updated description",
    "database_enabled": true
}
Field Type Notes
display_name string
description string
config_path string
deploy_config object
services / containers array
database_enabled / cache_enabled / email_enabled boolean
seed_from_reference string | null Max 512. null clears it — the only field this route accepts an explicit null for (every other null is ignored). A non-null value is refused 409 when the app has already been seeded or is running, 404 when the source is not this app or an app in its own group, 422 when it does not parse.
visibility string public or private. Changing it queues a route reconcile — the ALB rule is rewritten within about 15 s, and the app is fully converged within about 90 s (each gateway replica caches the hostmap for 60 s and expires on its own clock). No redeploy needed. A type: static app going public is slower: a private static app deliberately has no CloudFront distribution, so publishing has to create one — minutes, and the app's URL 404s until it lands.
force_remove_domains boolean Required to flip an app private while it has custom domains still attached; starts their teardown first. Only honoured on the app's production branch — domains are project-level, so tearing them down from another branch is refused. Without it the flip is a 409.

Response: 200 OK — the updated AppResponse.

AppResponse.warnings is a (usually empty) array of non-fatal strings. A visibility change queues the RECONCILE_ROUTING action that converges the data plane; that enqueue is best-effort, because the column is already committed by the time it runs and failing the request would report a flip that DID happen as failed. When it does fail, the response says so here rather than only in the server log. The agent's reconcile_static_visibility beat task re-drives it automatically.

Errors:

  • 404 Not Found — app does not exist.
  • 409 Conflict — flipping to private with custom domains still attached and no force_remove_domains; or force_remove_domains on a branch that does not own them.
  • 503 Service Unavailable — the custom-domain check or a teardown failed. The flip is refused rather than risking a private app with a live custom domain (BUGS #705 — this guard used to fail open).

Config Layers

GET /api/apps/{app_name}/config?branch={branch}

Returns the app's configuration in layers so a client can show which fields the repo owns and which the dashboard has overridden.

Field Meaning
base The repo-authored appmecca.yaml, refreshed by the agent on every successful build
overlay The dashboard-owned sparse field map ({path: {value, base}})
effective The merge the agent actually deploys from
source_map Each edited path marked "dashboard" so the UI can badge it
effective_yaml The effective config rendered as appmecca.yaml text, by the platform's own serializer — the seed for a raw-YAML editor. null when the app has no config at all, or when the render failed.

effective_yaml is rendered server-side on purpose

It is produced by the same serializer that generates a config file for the wizard, so the text a client shows is the text the platform itself would write. A client that stringifies effective in JavaScript instead would be a second derivation of the config shape — and JS and Python disagree on exactly the literals configs contain (no, on, 22:30, 1.10).


Save Config

PUT /api/apps/{app_name}/config?branch={branch}

Scope: deploy or higher.

Saves the app's configuration. The body carries exactly one of two fields — sending both is refused, and neither is resolved by a precedence rule:

Field Type Meaning
config object The full effective config, as an object
config_yaml string The same thing as appmecca.yaml text, parsed server-side

Whichever door is used, the request converges on one pipeline: bound → validate the merged config → refuse omissions → diff against the repo base → store only what differs → bump preflight_nonce → re-queue GIT_PREFLIGHT.

{"config_yaml": "containers:\n  - name: web\n    dockerfile: Dockerfile\n"}

Response

Field Meaning
overlay The sparse {path: {value, base}} map that was stored
changed_paths Paths added, changed or removed by this save
applies_live Changed paths that take effect immediately
requires_redeploy Changed paths that need a deploy
preflight_action_id The re-queued preflight's action id — null means the re-check could not be queued. The save still succeeded; run POST /api/apps/{app}/preflight/recheck to get a verdict.

Refusals (all 422)

Cause Detail
Both config and config_yaml sent Send either config or config_yaml, not both.
config_yaml is not a string config_yaml must be a string.
The YAML does not parse The parser's own message, including line and column
The YAML parses to something other than a mapping Names what a config must be
The config is too large, too deep, or aliases into an oversized graph Names the limit that was hit
The merged config fails validation validate_appmecca_yaml's errors
A field the repository defines was omitted Names the omitted fields

Omitting a field does not unset it

The overlay is applied on top of the repository's config, so there is no way to express "unset this". A submission that omits a field the repo defines is refused rather than silently accepted — accepting would report a save that changed nothing. Remove the field in appmecca.yaml instead.

The body is bounded before anything walks it

YAML resolves an alias by sharing the referenced node, so a tiny document can expand exponentially once anything walks it. Measured: a 431-byte document parses in 2 ms and expands to 4.8 billion leaves. The request is refused in ~1 ms by a node-count bound applied before validation.


Primary Services

PATCH /api/apps/{app_name}/primary-services?branch={branch}

Scope: deploy or higher.

Sets which services are the app's front doors — the ones whose URL and preview image are surfaced on the app card. Body:

{"primary": {"web": true, "admin": false}}

The override wins over the primary: field in appmecca.yaml and over auto-detection, per service: a name you don't mention keeps its existing verdict. Effects are immediate — no redeploy required.


Scale a Service

PATCH /api/apps/{app_name}/services/{service_name}/replicas?branch={branch}

Scope: deploy or higher.

Sets how many hosts a service runs on and queues the redeploy that applies it. The count is written into the app's config overlay — the same store Save Config writes — so it survives until the next change, and the redeploy is a no-build deploy of the image the service is currently running.

That last point is deliberate, and is what the image_tag in the response is for. The newest image an app has built is not always the one it is serving — a rollback (mecca app deploy --image-tag <tag> --no-build), a --no-deploy build, or a build whose deploy failed all leave the two different. Scaling redeploys what is running, so it can never quietly ship a release you had rolled back from.

Omit branch to resolve the branch the app is running on (falling back to its production branch). Note that GET /api/services/{app} resolves main first, so a client that scales without branch and then reads the service list without one can be looking at two different branches — pass the branch from the scale response.

Request body:

{"replicas": 3}
Field Type Required Description
replicas integer yes How many hosts to run the service on, 1-3

replicas is strictly an integer — true and "2" are refused rather than coerced, because a coerced boolean silently means 1 and would scale a service DOWN on a 200.

The 1-3 bound is not re-stated on this route. It is enforced in one place — validation of the merged config — so the route cannot come to disagree with the schema about what a legal count is.

Response: 200 OK

{
    "app_name": "euan-blog",
    "branch": "main",
    "service": "web",
    "previous": 1,
    "requested": 3,
    "action_id": "…",
    "preflight_action_id": "…",
    "overlay_paths": ["deploy.web.replicas"],
    "image_tag": "a1b2c3d"
}
Field Description
previous the count the service declared before this call
requested the count now declared
action_id the queued redeploy, or null for a no-op
preflight_action_id the re-queued config check, or null
overlay_paths every config path the saved overlay now carries
image_tag the running image the redeploy will deploy
deployed (no-op only) the count actually running, or null if unknown

action_id is null when the service already declares the requested count. That is a no-op, not a failure: nothing is written and no deploy is queued, so setting a value back to what it already was costs nothing. When the declared and running counts differ, the no-op's message says so and names mecca app redeploy — that is how a scale whose save landed but whose deploy did not is told apart from one that was never needed.

Refusal bodies

Every refusal this route raises for a condition a client might branch on sends detail as an object:

{"detail": {"code": "nothing_running", "message": "…"}}

code is a stable machine token; message is a complete sentence naming the remedy and is safe to show a user verbatim. Some codes carry extra machine-readable keys. Refusals a client cannot act on programmatically (a plain 404, a validation 422) keep a string detail.

code Status Extras Meaning
nothing_running 409 — the app is undeployed, or no container image is running on this branch, so there is nothing to scale. Nothing was written.
scale_in_flight 409 action_id, action_type, status a container-class action is already in flight for this app and branch. Attach to action_id rather than polling blind.
overlay_would_drop_fields 409 removed_paths a saved configuration override replaces a whole section, so writing this change would drop repository fields underneath it. Nothing was written; save the full configuration once from the Config editor, then scale.
scale_saved_not_deployed 500 (or the underlying status) saved, requested, previous, deployed, reason the declared count WAS saved and the redeploy could not be queued. Run mecca app redeploy <app>.

Errors:

  • 404 Not Found — unknown app, or a service the app does not declare. Both are 404 so a caller who cannot see the app learns nothing from the status code; the bodies differ — the service refusal lists the app's declared services, which is safe because it is reachable only once the group gate has passed.
  • 409 Conflict — one of the structured codes above; or the app's configuration changed between this request's read and its write (another save, a scale, or a drift resolution landed first). Nothing was written in any case.
  • 409 Conflict — the app is quarantined by its config check, or its group is over its storage grant. These are the deploy's own gates and they run before anything is saved.
  • 422 Unprocessable Entity — the count is outside 1-3, or is not a whole number; or the service is declared singleton: true and runs on exactly one host by design; or the service is a type: static site served from S3 + CloudFront and has no container to replicate; or the service's stored replicas is not a whole number, in which case the message names the field to correct. Each refusal names the remedy.
  • 503 Service Unavailable — the in-flight check could not be answered. Nothing was changed — retry.

⚠ Every refusal above leaves the stored configuration untouched. The one exception is scale_saved_not_deployed, which exists precisely to say so.

CLI: mecca app scale <app> <service> <n>.


Delete App

DELETE /api/apps/{app_name}?branch={branch}

Scope: admin.

Omit branch to resolve the app's live branch. This is a full purge: the database is backed up then dropped, every child record is removed, and the app's platform resources are cleaned up. Backup bundles are retained.

Response: 204 No Content.

Errors:

  • 404 Not Found — app does not exist.
  • 409 Conflict — the app is registered on more than one branch and no branch was given (pass one explicitly rather than let the API guess which to destroy).

Redeploy App

POST /api/apps/{app_name}/redeploy?branch={branch}

Scope: deploy or higher.

Queues a rebuild followed by a deploy, and returns immediately.

Query parameters:

Parameter Type Default Description
branch string resolved Branch to redeploy. Omit to resolve automatically: the running branch if exactly one is running, otherwise the app's production branch. Redeploy is reversible (it rebuilds and replaces containers; it does not drop the database or purge AWS), so an omitted branch resolves rather than being refused. Pass explicitly to target a specific branch.
no_build boolean false Skip the build and deploy the app's latest successful image
skip_preflight boolean false Bypass the git-repo preflight gate (escape hatch for a false-negative check)

Response: 202 Accepted

{
    "action": "deploy",
    "action_id": "550e8400-e29b-41d4-a716-446655440000",
    "app_name": "my-web-app",
    "image_tag": "abc12345",
    "message": "Deploy queued (no build) — using image tag abc12345"
}

The full rebuild path returns the build service's own envelope (build tag, action id and status).

Errors:

All non-2xx responses carry a structured detail body so operators can diagnose without grepping logs.

  • 400 Bad Request — caller-side validation error, e.g. --no-build with no successful build to reuse.
  • 404 Not Found — app does not exist (on the resolved branch).
  • 409 Conflict — the app is running on more than one branch and no branch was given (pass one explicitly rather than let the redeploy pick); or the app is quarantined by preflight, or its group is over its storage entitlement.
  • 422 Unprocessable Entity — a typed build-service failure (config_path_required:, git_auth_missing:, or any other subclass). The detail prefix names the failure class.
  • 502 Bad Gateway — ecr_setup_failed: (build-project / registry provisioning failure).
  • 500 Internal Server Error — unexpected exception while triggering the build. The detail is build_service_error: <ExceptionType>: <message> so the operator sees what raised, never an opaque body.

Retry a Failed Deploy

POST /api/apps/{app_name}/retry

Scope: deploy or higher.

An in-place retry of the failing phase of the app's most recent failed deploy. The retry reuses the failed attempt's placement, image tag and blue/green slot — it lands on the same host, in the same slot, rather than re-planning from scratch.

Response: 202 Accepted with the new action id.

Optional inputs (FEATURES #418):

Input Where Meaning
branch query Retry the latest failure on that branch. Absent = the app's latest failure on any branch (unchanged).
expected_action_id JSON body {"expected_action_id": "<id>"} The failed action the caller is looking at. The full stored id (36–256 characters, compared case-insensitively) — a UUID prefix is a 422.

Sending neither still resolves the same row it always did (the app's latest failure, on any branch); both CLIs send neither. ⚠ Since BUGS #1417 that is no longer the whole story — the two refusals below run on every caller, with or without a body. See the envelope note under them.

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"expected_action_id": "3f0c5d1e-8b1a-4c3e-9d7a-2f6e1b0c4a55"}' \
  "https://api.qa.appmecca.net/api/apps/shop/retry?group=acme&branch=main"

⚠ expected_action_id is checked BEFORE the retriability classifier, the retry cap and the preflight gate. If it is not the latest failure, nothing is queued and the answer is a 409 about the row you asked about — never a retriability verdict about a different row:

{
  "detail": {
    "code": "retry_target_moved",
    "message": "Not retried: the failed deploy you asked about (3f0c5d1e) is not the latest failure for 'shop' on branch 'main'. …",
    "expected_action_id": "3f0c5d1e-8b1a-4c3e-9d7a-2f6e1b0c4a55",
    "latest_failed_action_id": "9a1b…",
    "latest_failed_branch": "main"
  }
}

Two further refusals run before anything is queued. ⚠ Both apply to EVERY caller (BUGS #1417) — with a body or without, from either CLI, the tenant dashboard or the admin console. The order is fixed: retry_target_ambiguous → retry_target_moved (body only) → retry_superseded → preflight → retriability → cap, so the answer always describes the earliest thing that is wrong.

detail.code When
retry_target_ambiguous The app name is registered in more than one group (BUGS #692). Failed deploys cannot be attributed to one of them, so no row is named.
retry_superseded A later DEPLOY, RETRY or STATIC_DEPLOY of the same app and branch exists that did not fail and was not cancelled — including the first retry of this same failure. The check is repeated under an advisory lock on the app and branch in the same transaction as the insert, so two concurrent retries of one failure queue exactly one. A retry clones the failed attempt's image, placement and blue/green slot, so retrying the older failure would run the old image over the newer attempt.

A mixed-shape app's own other half is not a supersession: a DEPLOY and the STATIC_DEPLOY queued alongside it share one deployment_id, and the static half is inserted second. Only a later attempt belonging to a different deploy counts.

The ids named are the caller's own app's rows — the route is group-gated, and a name held by two groups is refused before any row is named.

The detail envelope depends on expected_action_id

Both refusals carry one sentence, in two envelopes:

  • With expected_action_id (the admin console), detail is an object — {"code": …, "message": …}, plus later_action_id, later_action_type and later_status for retry_superseded. Unchanged, byte for byte.
  • Without it (both CLIs, the tenant dashboard — including a body of {} or {"expected_action_id": null}), detail is that same sentence as a plain string, the shape this route's other 409s already use. A released CLI prints an object detail as a key dump or a Python dict repr, so the string is what makes the remedy readable without a client upgrade.
{
  "detail": "Not retried: a later DEPLOY (9a1b2c3d, completed) of 'shop' on branch 'main' exists. Retrying the older failure would reuse its image and blue/green slot over it. Redeploy instead."
}

Errors:

  • 404 Not Found — no failed deploy to retry (on branch, when given).
  • 409 Conflict — retry_target_moved, retry_target_ambiguous or retry_superseded (above); or the failure is not retriable (the detail names the classified reason and what to do instead), the retry cap is exhausted, or the app is quarantined by preflight.
  • 422 Unprocessable Entity — expected_action_id is shorter than 36 or longer than 256 characters.

Reconcile Routes

POST /api/apps/{app_name}/reconcile?branch={branch}

Scope: deploy or higher.

Re-asserts the app's live route against its current visibility without changing any app state. Useful when you suspect drift and don't want to wait for the periodic drift check. Returns the new action id.


Set Production Branch

POST /api/apps/{app_name}/set-production?branch={branch}

Scope: deploy or higher.

Promotes one branch to be the project's production version. A "project" is the set of app rows sharing a repository within a group; at most one branch per project may be production, and the clear-then-promote runs in a single transaction so the invariant never breaks.

{
    "app_name": "my-web-app",
    "branch": "main",
    "is_production": true,
    "storage_identity_note": "Promotion does not move storage: each branch keeps its own drives and distributions. …"
}

Promotion does not move data

Each branch keeps its own drives and its own edge-cache distribution. Promoting a branch changes which one the project card features — it does not migrate storage.


Recheck Preflight

POST /api/apps/{app_name}/preflight/recheck?branch={branch}

Scope: deploy or higher.

Re-runs the git-repo preflight — a clone-and-validate check that confirms the env can reach the repo + branch, that an appmecca.yaml exists, that the effective config is valid, and that the referenced Dockerfiles exist. Bumps preflight_nonce and queues a fresh GIT_PREFLIGHT RPC (executed by the host agent — the API is jailed from the git deploy key). The current preflight_status is left untouched while the check runs (a ready app stays deployable) and the new verdict overwrites it when the agent lands.

Query parameters:

Parameter Type Default Description
branch string main Branch to re-check

Response: 202 Accepted

{
    "action": "preflight_recheck",
    "action_id": "3fa12ab8...",
    "app_name": "my-web-app",
    "preflight_status": "no_access",
    "message": "Re-check queued — the verdict updates when it completes."
}

An exempt app (platform apps are born exempt) has nothing to check — the response still carries 202 but reports preflight_status: "exempt" with no action_id, since nothing was queued.

Errors:

  • 404 Not Found — app does not exist for the given name/branch (cross-tenant probes 404, never 403).

Data Model

Apps are stored in the apps PostgreSQL table. See Schema Reference for the full table definition.

Key fields:

Field Description
app_name Canonical name — {group-slug}-{app-slug} for a tenant app, bare for a platform app
slug Branch-invariant identifier used in URLs and resource names
group_slug The owning group (surfaced on the list endpoint so two tenants' identically-named apps are distinguishable)
deploy_status Current status: registered, building, deploying, running, failed
latest_build Tag of the most recent build
build_count Total number of builds triggered
deploy_config JSONB — deployment configuration from appmecca.yaml
deploy_config_overrides JSONB — the dashboard-owned override overlay (config field map + primary front-door map)
services JSONB — service definitions
containers JSONB — container definitions
ecr_uri The app's private image-registry URI
seed_from_reference / seeded_at One-shot seed-from-backup source, and when it ran