App catalog
Browse templates, prepare installations, and configure apps.
An app is a JSON definition that installs a set of containers as a project in one click — with its images, wiring, generated secrets, curated settings form, and connection card already described. This API covers browsing the catalog, installing, adding your organization's own custom apps, and reading or updating an installed app afterwards.
In the dashboard this is the Apps tab. The definition format is documented in the App catalog JSON reference, and authoring workflow in Add an app.
These operations are available with supported self-hosted and Cloud providers. A fixed organization scope requires a direct compatible Cloud connection; see Cloud compatibility.
List the catalog
GET /api/apps/catalogThe runtime catalog is the copy bundled in your build, overlaid by a live fetch of the curated catalog from the Synq repo (refreshed on a 10-minute TTL, stale-while-revalidate), plus your organization's custom apps. A failed fetch or an offline instance simply keeps serving the last-good copy, so the overlay can never break the catalog.
Overlay trust model
The overlay is fetched over HTTPS from Synq's own repository — repo-curated, with no signing and no user uploads in that channel — and every entry is shape-validated before it can drive an install, using the same strict validator as the bundled set. Your organization's custom apps are a separate, always-unverified channel.
Because the overlay can be ahead of your instance, entries carry compatibility flags:
Prop
Type
Each entry also carries hosting ("experimental" marks a stack that runs but is heavy or not
production-grade upstream) and, for the few apps that declare one, minResources — the host floor
you can check with host-fit before installing. Apps marked unlisted are
absent from this list by design: they stay installable by id, reached through another app's wizard.
Get one app
GET /api/apps/catalog/:idReturns the full definition — services, config fields, endpoints, settings, connection. Curated apps win over custom ones: a custom app can never shadow a verified id (also enforced at upload).
This is config metadata only, never secrets — generated values are minted at install, so nothing sensitive exists to return yet.
The response also carries a draft alongside data: this organization's open, never-deployed
draft of the same app, if one exists.
Install adopts an open draft
An install request for the same name adopts that draft rather than starting clean. So a client must render the draft's stored configuration, not the app's defaults — otherwise Install silently changes what the operator set up last time.
Check a destination
GET /api/apps/catalog/:id/host-fit?deployTarget=server&serverId=<id>Does a machine meet what the app declares in
minResources? Ask before installing, so the operator sees the
shortfall instead of discovering it from a failed deploy.
Prop
Type
{
"data": {
"minResources": { "memoryMb": 8192, "cpuCores": 4 },
"capacity": { "cpuCores": 2, "memoryMb": 2048, "source": "docker" },
"fit": {
"ok": false,
"memory": { "needed": 8192, "available": 2048 },
"cpu": { "needed": 4, "available": 2 }
}
}
}Advisory, not the gate
This endpoint never refuses anything — it reports. The enforcement point is deploy preflight, which
reads the same declaration through the same comparison and fails a first deploy with
HOST_RESOURCES_INSUFFICIENT. Both are described under
Host requirements.
A server ID must be accessible in the current organization. Cloud targets and machines that cannot be
probed can report capacity.source: "unknown" with fit.ok: true; that means no measured capacity
comparison was available, not that the host capacity was verified.
Install an app
For a template app, installation creates or updates a project draft and returns projectId. Submit a
deployment for that project to run it. A flow app returns flowHref for its
setup wizard.
const result = await ship.apps.install({
templateId: "ghost",
name: "my-blog",
routes: [
{
service: "ghost",
port: 2368,
mode: "custom",
customDomain: "blog.acme.com",
},
],
});
console.log(result);curl -X POST "$SYNQ_URL/api/apps" \
-H "Authorization: Bearer $SYNQ_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"templateId": "ghost",
"name": "my-blog",
"routes": [
{ "service": "ghost", "port": 2368, "mode": "custom", "customDomain": "blog.acme.com" }
]
}'Prop
Type
Each routes entry:
Prop
Type
No routes means no domain
Public hostnames come only from routes. A service with no entry gets no public route — a
hostname is never invented on your behalf. Omit routes entirely and the app installs port-only,
which is a perfectly good outcome for a database or an internal tool.
Custom apps
POST /api/apps/custom
GET /api/apps/custom
GET /api/apps/custom/:appId
PUT /api/apps/custom/:appId
DELETE /api/apps/custom/:appId
POST /api/apps/custom/validate
POST /api/apps/custom/compose
GET /api/apps/custom/from-project?projectId=The dashboard's template builder is a client of exactly these
endpoints. validate is a dry run of the save and returns { ok, errors, warnings }, every issue as
{ path, message } (for example services[0].image). compose turns a docker-compose file into
template services, configFields and suggested endpoints without saving anything. from-project
drafts a template from one of your projects; secret values are never copied. PUT replaces a
template, and its body's id must equal :appId: installed apps point at that id.
POST accepts a full app definition as the request body. The body is
gated here only as "must be a JSON object" — the authoritative validation runs in the service and is
the same strict check as the curated catalog, shape plus every referential rule.
Four constraints are enforced:
- Always unverified.
verifiedandavailableare set by the server, so a file claiming"verified": trueis ignored. kindmust betemplate. Flow apps are rejected.- The
idcannot shadow a built-in app. - Validation errors are returned with the offending detail, including a distinct message when the
definition targets a newer catalog
schemaVersionthan the instance supports.
A custom app installs through the normal services pipeline with the same in-container boundary as any project you deploy yourself — no new privilege.
Curated settings
GET /api/projects/:id/app-settings
PATCH /api/projects/:id/app-settingsGET returns the app's settings schema together with current values, so a client can render the
form without knowing the app. PATCH applies a safe env merge — only the keys you send are touched:
Prop
Type
Empty-string semantics differ by field kind, which matters when a form re-submits untouched inputs:
| Field | value: "" means |
|---|---|
| Non-secret | Clear the override, reverting to the app default |
| Secret | Leave the stored value unchanged |
Fields marked requiresRedeploy in the definition take effect only after a redeploy; the response
and the dashboard both surface that.
Connection details
GET /api/projects/:id/app-connectionResolves the app's connection outputs against the running services — public URLs, generated keys, composed connection strings — and returns them ready to use, including any labeled variants such as a public versus internal-network form.
This route requires project:write, deliberately
It returns fully decrypted credentials — service-role JWTs, database passwords embedded in
postgres://… URLs — which bypass database row-level security. The env listing can mask secrets at
project:read; this surface cannot, so it sits above the read tier on purpose. A read-only grant or
a project:read MCP token must not be able to exfiltrate live credentials. Treat the response as
secret material.
MCP exposure
Every route in this module is also exposed as an MCP tool, so an AI agent can browse
the catalog, install an app, and read its settings and connection details. Tool exposure is an
opt-in allowlist — a route becomes a tool only when its spec declares an mcp block — and the
module-level hard-deny list covers only tokens, auth, and mcp. apps is not on it.
Every MCP call re-checks the caller's permissions, so the tags in the operation table apply unchanged. What that means in practice:
| Agent token | Can do |
|---|---|
Read-only (project:list / project:read) | Browse the catalog, read an installed app's settings schema |
project:write | Also install apps and read decrypted connection credentials |
Scope agent tokens deliberately
Because app-connection is a tool and it returns live credentials, a project:write agent token can
read an installed app's database passwords and service-role keys. If you are handing a token to an
agent that only needs to inspect things, issue a read-only one.
Errors
400 — install refused
Install validates the app and your routing choices, and returns the reason as the message. Common
ones: the app is still marked coming soon (available: false); it needs a newer Synq
(requiresUpdate, reported as Requires Synq ≥ X); the app id is unknown; a routes entry
names a service the app does not declare, or a port that service does not serve; two routes entries
target the same service and port; or mode: "custom" was sent without a customDomain. A missing
templateId is rejected the same way.
400 — invalid app definition (custom upload)
The uploaded JSON failed the strict schema, and the message carries the offending detail — a bad
enum, a missing required field, or a dangling reference such as a service that no declared service
matches. Distinct messages cover the three non-shape rejections: a flow app, an id that shadows a
built-in, and a definition targeting a newer catalog schemaVersion than this instance supports.
400 — settings rejected
From PATCH /app-settings: the project is not an app or declares no settings
(This app has no configurable settings), a change names a key the app does not define
(Unknown setting: service.KEY), a required field was cleared, a value failed its pattern or
numeric bounds, or the named service does not exist on the project.
409: no apps domain for a free subdomain
A routes entry used mode: "free" on a server with no apps domain for free subdomains: no
SYNQ_APPS_BASE_DOMAIN, no Settings value, no public panel hostname and no public IPv4 address (an
IPv6-only or private-network server). The code is FREE_SUBDOMAIN_UNAVAILABLE and the message says why. Set an
apps domain with a wildcard DNS record, use mode: "custom" with your own hostname, or mode: "port" for no
public route. Where an apps domain exists (including <ip-with-dashes>.sslip.io), mode: "free" is saved as
the custom domain <slug>.<apps domain>.
404 — unknown app
GET /api/apps/catalog/:id returns { "error": "Unknown app" } when the id is neither a curated app
nor one of your organization's custom apps. List valid ids with GET /api/apps/catalog.
Related guides
- App catalog JSON — every field in an app definition
- Add an app — submit a curated app, or upload a custom one
- Services API — the services an installed app is made of
- Projects API — the project an app installs into
Operations
Connections, storage, and apps
| Operation | SDK | REST API |
|---|---|---|
| Read curated settings for an installed catalog app. | projects.getAppSettings(id) | GET /api/projects/:id/app-settingsproject:read |
| Apply curated app setting changes. | projects.updateAppSettings(id, input) | PATCH /api/projects/:id/app-settingsproject:write |
| Read app connection details; these may include credentials and require write access. | projects.getAppConnection(id) | GET /api/projects/:id/app-connectionproject:write |
Resource methods
| Operation | SDK | REST API |
|---|---|---|
| List available app templates. | apps.listCatalog() | GET /api/apps/catalogproject:list |
| List the organization’s custom app definitions. | apps.listCustom() | GET /api/apps/customproject:list |
| Validate and save a custom app definition. | apps.saveCustom(input) | POST /api/apps/customproject:write |
| Check a custom template without saving it; returns every error and warning with its path. | apps.validateCustom(input) | POST /api/apps/custom/validateproject:list |
| Convert a docker-compose file into template services, config fields and suggested endpoints. | apps.convertCompose(input) | POST /api/apps/custom/composeproject:list |
| Draft a custom template from an existing project; secret values are never copied. | apps.customFromProject(input) | GET /api/apps/custom/from-projectproject:read |
| Read one custom template as its author wrote it. | apps.getCustom(id) | GET /api/apps/custom/:appIdproject:list |
| Replace an existing custom template; its id cannot change. | apps.updateCustom(id, input) | PUT /api/apps/custom/:appIdproject:write |
| Create or update an installation draft and return its project, with any template host ports moved because another app on the server used them. | apps.install(input) | POST /api/appsproject:write |
| Read a template and its accessible installation draft. | apps.getCatalogEntry(id) | GET /api/apps/catalog/:idproject:list |
| Check template requirements against a destination. | apps.hostFit(id, input?) | GET /api/apps/catalog/:id/host-fitproject:list |
| Remove a custom catalog definition. | apps.removeCustom(id) | DELETE /api/apps/custom/:appIdproject:write |