Documentation
API v1
Automate nouva projects, services, deployments, and operations with the stable, versioned REST API.
Start with v1
Base URL
https://api.nouva.sh/v1OpenAPI document
https://api.nouva.sh/openapi.jsonGET /openapi.json is public and describes only the customer-facing /v1 routes. It uses bearer authentication. Nouva does not publish a Swagger UI. Request and response bodies are JSON, except the bucket upload route, which takes multipart form data.
List projects
export NOUVA_API_TOKEN="nouva_v1_REPLACE_WITH_YOUR_TOKEN"
export NOUVA_API_BASE="https://api.nouva.sh/v1"
curl --include "$NOUVA_API_BASE/projects?limit=20" \
--header "Authorization: Bearer $NOUVA_API_TOKEN"Authentication and project reach
Current workspace admins create, rotate, and revoke API tokens in Dashboard Settings. A token secret is shown only once when it is created or rotated, so store it in your CI secret manager rather than source control.
Bearer token
Send Authorization: Bearer <token> on every authenticated request. Secrets start with nouva_v1_.
Project reach
Tokens can cover all workspace projects or an explicit project allowlist. Selected project tokens cannot create projects or use workspace-wide server routes.
Expiry
Every token expires between 1 and 365 days after creation, and defaults to 90 days. Non-expiring tokens are not supported.
Rotation and revocation
Rotating revokes the old secret immediately and issues a replacement that keeps the same scopes and project reach. A token also stops working if its owner loses workspace admin.
Scopes
Choose only the scopes your integration needs. Every write scope automatically includes its matching read scope.
| Area | Read scope | Write scope |
|---|---|---|
| Projects | projects:read | projects:write |
| Environments | environments:read | environments:write |
| App and worker services | services:read | services:write |
| Databases | databases:read | databases:write |
| Deployments | deployments:read | deployments:write |
| Logs | logs:read | Read only |
| Metrics | metrics:read | Read only |
| Volumes | volumes:read | volumes:write |
| Backups | backups:read | backups:write |
| Domains | domains:read | domains:write |
| Buckets | buckets:read | buckets:write |
| Servers | servers:read | Read only |
| GitHub sources | sources:read | Read only |
Endpoint reference
Every path below is relative to https://api.nouva.sh/v1. The OpenAPI document is the authoritative source for request and response bodies.
Projects
Creating a project requires an all-projects token and a connected, online server.
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /projects | projects:read | List projects. |
POST | /projects | projects:write | Create a project. |
GET | /projects/{projectId} | projects:read | Read one project. |
PATCH | /projects/{projectId} | projects:write | Rename a project. |
DELETE | /projects/{projectId} | projects:write | Delete an empty project. It stays readable until the agent removes its network. |
GET | /projects/{projectId}/environments | environments:read | List environments. |
POST | /projects/{projectId}/environments | environments:write | Create an environment. |
Services
These routes cover app and worker services only. Databases have their own routes.
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /projects/{projectId}/services | services:read | List app and worker services. |
POST | /projects/{projectId}/services | services:write | Create a service and its first deployment. |
GET | /services/{serviceId} | services:read | Read one service. |
PATCH | /services/{serviceId} | services:write | Update service configuration. |
DELETE | /services/{serviceId} | services:write | Request agent-confirmed cleanup of a service. |
GET | /services/{serviceId}/release-config | services:read | Read the pre-activation job and verification command of an app or worker. |
PUT | /services/{serviceId}/release-config | services:write | Set or clear release phases; applies from the next deployment. |
GET | /services/{serviceId}/changes | services:read | Preview staged changes; a database needs databases:read. |
POST | /services/{serviceId}/actions/apply-changes | services:write | Apply staged changes; a database needs databases:write. |
Databases
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /projects/{projectId}/databases | databases:read | List databases. |
POST | /projects/{projectId}/databases | databases:write | Provision a database. |
GET | /databases/{serviceId} | databases:read | Read one database. |
PATCH | /databases/{serviceId} | databases:write | Update database configuration. |
DELETE | /databases/{serviceId} | databases:write | Request agent-confirmed cleanup of a database. |
POST | /databases/{serviceId}/actions/restart | databases:write | Restart a database. |
POST | /databases/{serviceId}/actions/retry-provision | databases:write | Retry a failed provision. |
POST | /databases/{serviceId}/actions/postgres-pitr | databases:write | Start a Postgres point-in-time restore. |
Deployments and logs
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /services/{serviceId}/deployments | deployments:read | List deployments. |
POST | /services/{serviceId}/deployments | deployments:write | Replay a deployment's source snapshot. |
POST | /services/{serviceId}/actions/deploy-current-source | deployments:write | Build the branch tip with the applied configuration. |
POST | /services/{serviceId}/actions/deploy-commit | deployments:write | Build one explicitly named commit with the applied configuration. |
POST | /services/{serviceId}/actions/push-preview | deployments:read + services:read | Report which services a push of a commit, or of a path list, would deploy. |
GET | /services/{serviceId}/push-decisions | deployments:read + services:read | List recent pushes and why each did or did not deploy the service. |
GET | /deployments/{deploymentId} | deployments:read | Read one deployment. |
POST | /deployments/{deploymentId}/actions/restart | deployments:write | Restart the running deployment. |
POST | /deployments/{deploymentId}/actions/rollback | deployments:write | Roll back to this deployment. |
GET | /deployments/{deploymentId}/release-jobs | deployments:read | Read how the deployment's release phases ran. |
POST | /deployments/{deploymentId}/release-jobs/pre_activation/actions/resolve | deployments:write | Retry a stopped pre-activation job, or mark an unknown outcome succeeded. |
GET | /deployments/{deploymentId}/logs/build | logs:read | Read build logs. |
GET | /deployments/{deploymentId}/logs/runtime | logs:read | Page through runtime logs. |
GET | /schedule-runs/{runId}/logs | logs:read | Page through worker job logs. |
Worker schedules
Worker schedules are governed by the services scopes, not a scope of their own.
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /services/{serviceId}/schedules | services:read | List schedules for a worker. |
POST | /services/{serviceId}/schedules | services:write | Create a cron schedule. |
PATCH | /schedules/{scheduleId} | services:write | Update or disable a schedule. |
DELETE | /schedules/{scheduleId} | services:write | Delete a schedule. |
GET | /schedules/{scheduleId}/runs | services:read | List schedule runs. |
POST | /schedules/{scheduleId}/runs | services:write | Start a run now. |
Volumes and backups
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /projects/{projectId}/volumes | volumes:read | List volumes. |
GET | /volumes/{volumeId} | volumes:read | Read one volume. |
PATCH | /volumes/{volumeId} | volumes:write | Rename a volume. |
DELETE | /volumes/{volumeId} | volumes:write | Queue volume deletion. |
POST | /volumes/{volumeId}/actions/attach | volumes:write | Stage a volume attachment. |
POST | /volumes/{volumeId}/actions/increase-reservation | volumes:write | Increase the reserved size. |
GET | /volumes/{volumeId}/backups | backups:read | List backups for a volume. |
GET | /backups/{backupId} | backups:read | Read one backup. |
PATCH | /backups/{backupId} | backups:write | Lock or unlock a backup. |
DELETE | /backups/{backupId} | backups:write | Queue backup deletion. |
POST | /services/{serviceId}/backups | backups:write | Take a backup now. |
GET | /services/{serviceId}/backups/schedule | backups:read | Read the backup schedule. |
PATCH | /services/{serviceId}/backups/schedule | backups:write | Toggle daily, weekly, and monthly backups. |
POST | /backups/{backupId}/actions/restore | backups:write | Restore a backup into a pending volume. |
POST | /services/{serviceId}/actions/apply-restore | backups:write | Cut the service over to the restored volume. |
GET | /services/{serviceId}/database-imports | backups:read | List external backup imports for a database service. |
POST | /services/{serviceId}/database-imports | backups:write | Register an external backup import and its SHA-256 digest. |
GET | /database-imports/{importId} | backups:read | Read import progress, failure category, and receipt. |
POST | /database-imports/{importId}/artifact | backups:write | Upload the backup artifact for a registered import. |
POST | /database-imports/{importId}/actions/start | backups:write | Verify the artifact and stage it onto a new volume. |
POST | /database-imports/{importId}/actions/cancel | backups:write | Abandon an import and release everything it holds. |
Compose migrations
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /compose-migrations/supported-subset | projects:read | List every Compose directive and what Nouva does with it. |
POST | /compose-migrations/preview | projects:read | Plan a Compose file without storing it. Values are never echoed. |
GET | /projects/{projectId}/compose-migrations | projects:read | List a project's Compose migrations. |
POST | /projects/{projectId}/compose-migrations | projects:write | Record a reviewed plan and keep the file encrypted until retirement. |
GET | /compose-migrations/{migrationId} | projects:read | Read a migration's status, plan, staging progress, and checklist. |
GET | /compose-migrations/{migrationId}/host-readiness | projects:read | Check ports 80 and 443, capacity, and kernel on the project's server. Add ?sourceService= (and optionally &storageSizeGb=) to ask whether staging that one service would be refused. |
GET | /compose-migrations/{migrationId}/source | projects:write | Download the stored Compose file while it is retained. |
POST | /compose-migrations/{migrationId}/actions/stage | projects:write | Create or skip the next service in stage order. Source containers are untouched. |
POST | /compose-migrations/{migrationId}/actions/start-cutover | projects:write | Open the cutover checklist once every service is staged or skipped. |
POST | /compose-migrations/{migrationId}/actions/mark-checklist-item | projects:write | Mark or reopen one cutover step. Nouva records it; the operator does it. |
POST | /compose-migrations/{migrationId}/actions/record-data-decision | projects:write | Record that a database's data moved, or why it is not migrated. |
POST | /compose-migrations/{migrationId}/actions/complete | projects:write | Finish once the checklist and data steps are settled; deletes the stored file. |
POST | /compose-migrations/{migrationId}/actions/abandon | projects:write | End a migration and delete its stored file. Nothing on the server changes. |
Domains
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /services/{serviceId}/domains | domains:read | List custom domains. |
POST | /services/{serviceId}/domains | domains:write | Add a custom domain. |
DELETE | /services/{serviceId}/domains/{domainId} | domains:write | Remove a custom domain. |
Buckets
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /projects/{projectId}/buckets | buckets:read | List buckets. |
POST | /projects/{projectId}/buckets | buckets:write | Create a bucket. |
GET | /buckets/{bucketId} | buckets:read | Read a bucket and its per-environment instances. |
DELETE | /buckets/{bucketId} | buckets:write | Queue bucket deletion. |
GET | /buckets/{bucketId}/objects | buckets:read | List objects and prefixes. |
GET | /buckets/{bucketId}/objects/download-url | buckets:read | Get a public or signed download URL. |
POST | /buckets/{bucketId}/objects/upload | buckets:write | Upload one object as multipart form data. |
DELETE | /buckets/{bucketId}/objects | buckets:write | Delete objects by key or prefix. |
Servers and metrics
Every server route requires an all-projects token. Selected-project tokens receive 403.
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /servers | servers:read | List servers. |
GET | /servers/{serverId} | servers:read | Read one server. |
GET | /servers/{serverId}/health | servers:read | Read derived status and readiness. |
GET | /servers/{serverId}/capabilities | servers:read | Read agent capability flags. |
GET | /services/{serviceId}/metrics | metrics:read | Read service CPU, memory, network, and disk series. |
GET | /volumes/{volumeId}/metrics | metrics:read | Read volume usage series. |
GET | /servers/{serverId}/metrics | metrics:read + servers:read | Read host-level metrics. |
GitHub sources
Source discovery is workspace-level. It reads only installations already connected to this workspace, and never returns installation or OAuth tokens.
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /github/installations | sources:read | List connected GitHub installations and the setup state. |
GET | /github/installations/{installationId}/repositories | sources:read | List repositories the installation can build. |
GET | /github/installations/{installationId}/repositories/{repositoryId}/branches | sources:read | List branches of one accessible repository. |
Addresses and release provenance
A service response carries every address it answers on, so a client never concatenates a platform suffix of its own. A deployment response carries the source and image identity the build actually used. Anything Nouva has not verified is null rather than a guess: a database has no providedHostname, and a build that has not pushed yet has no image.
Service addresses, source, and release
{
"service": {
"id": "service_...",
"subdomain": "web-abc",
"endpoints": {
"providedHostname": "web-abc.up.nouva.cloud",
"publicUrl": "https://web-abc.up.nouva.cloud",
"customUrls": ["https://shop.example.com"],
"internalHost": "web.project.internal",
"internalPort": 3000
},
"source": {
"type": "github",
"repository": "acme/storefront",
"repositoryId": 123456,
"branch": "main",
"installationId": 654321
},
"release": {
"currentDeploymentId": "deployment_live",
"inFlightDeploymentId": "deployment_building",
"latestDeploymentId": "deployment_building"
}
}
}release.currentDeploymentIdis the deployment answering requests right now.release.latestDeploymentIdis the newest attempt, whatever became of it. The two differ for the whole of a build and permanently after a failed one, so compare the deployment ID you requested againstcurrentDeploymentIdbefore treating a release as live.sourceis the service's current binding, and moves when youPATCHthe branch. A deployment records the commit it was built from, so usecommitShato verify a specific release.GET /services/{serviceId}/domainsreturns the sameprovidedHostnameandpublicUrlalongside the custom-domain records.
Deployment provenance
{
"deployment": {
"id": "deployment_...",
"serviceId": "service_...",
"status": "LIVE",
"commitSha": "9f1c2b8e...",
"appBuildType": "dockerfile",
"build": {
"buildRoot": "apps/api",
"dockerfilePath": "apps/api/Dockerfile",
"dockerContextPath": ".",
"dockerBuildStage": "runner",
"publishDirectory": null
},
"image": {
"repository": "registry.nouva.sh/project_.../service_...",
"tag": "deployment_...",
"digest": "sha256:..."
}
}
}build and imagecome from the deployment's own frozen configuration, not the service's current settings, so they stay accurate after the service is reconfigured. Registry credentials, environment values, and internal error text never appear in either object.
Deleting services and what survives
DELETE /services/{serviceId} and DELETE /databases/{serviceId} record the request and queue cleanup for the agent on your server. Nouva never removes the container from the control plane, so the service stays readable, with status DELETING, until the agent reports the cleanup finished. Both routes are safe to repeat: a second call answers with the same in-flight cleanup rather than starting another one.
Delete a service and wait for cleanup
curl -sS --request DELETE "$NOUVA_API/services/$SERVICE_ID" \\
--header "Authorization: Bearer $NOUVA_API_TOKEN"
# {
# "cleanup": {
# "serviceId": "service_...",
# "state": "cleaning",
# "requestedAt": "2026-09-05T00:00:00.000Z",
# "retainedVolumeIds": ["volume_..."]
# }
# }
for _ in $(seq 1 60); do
CODE=$(curl -sS -o /dev/null -w '%{http_code}' "$NOUVA_API/services/$SERVICE_ID" \\
--header "Authorization: Bearer $NOUVA_API_TOKEN")
if [ "$CODE" = "404" ]; then echo "Cleanup confirmed."; break; fi
sleep 5
done- While the request is outstanding, the service DTO carries a
deletionobject with the samestate,requestedAt, andretainedVolumeIds.stagedmeans the request is recorded,cleaningmeans agent work is outstanding. A404on the service is the confirmation that cleanup completed. - Volumes are never cascaded. A volume attached to the service is detached and kept, and the IDs are listed in
retainedVolumeIds. Deleting the stored data is a separate authorized operation:DELETE /volumes/{volumeId}. - Cleanup that fails on the server is retried by the control plane, and re-issuing the same
DELETEre-queues it immediately. The request is refused with a412while the service is in a state that forbids removal, and the staged request is preserved. DELETE /projects/{projectId}still refuses a project that contains services, so delete each service and wait for its404before deleting the project.- A project delete answers
204once it is accepted, and the agent on your server then removes the project's network.GET /projects/{projectId}keeps returning the project until that finishes, then answers404. Meanwhile every change to the project answers412 precondition_failedwith thedetails[].codeproject_being_deleted. If the removal fails, the project stays and accepts changes again, and re-issuing the sameDELETEqueues it again.
Staged changes and building current source
A PATCH that only changes environment variables does not restart anything: it stages the new map and leaves the running release alone. GET /services/{serviceId}/changes reports exactly what is staged, and POST /services/{serviceId}/actions/apply-changes promotes it. Both routes work for databases too, and take databases:read and databases:write for a database service.
Review staged changes, then apply them
curl -sS "$NOUVA_API/services/$SERVICE_ID/changes" \
--header "Authorization: Bearer $NOUVA_API_TOKEN"
# {
# "changes": {
# "serviceId": "service_...",
# "state": "staged",
# "environment": {
# "added": ["FEATURE_FLAG"],
# "changed": ["API_TOKEN"],
# "removed": [],
# "stagedAt": "2026-09-06T09:00:00.000Z"
# },
# "resources": {
# "active": { "cpuMillicores": 500, "memoryBytes": "536870912", ... },
# "desired": { "cpuMillicores": 1000, "memoryBytes": "1073741824", ... },
# "stagedAt": null
# },
# "volume": null,
# "deletion": null
# }
# }
curl -sS --request POST "$NOUVA_API/services/$SERVICE_ID/actions/apply-changes" \
--header "Authorization: Bearer $NOUVA_API_TOKEN" \
--header "Idempotency-Key: $(uuidgen)"
# 202 { "apply": { "changeSetId": "...", "serviceId": "service_...",
# "state": "applying", "deploymentId": "deployment_..." } }- Names, never values. The environment diff lists variable names under
added,changed, andremoved. No route in the public API returns an environment value, staged or applied. resources.desiredis what the service would run once the staged configuration is applied;resources.activeis what it runs now. They are equal on a service with nothing staged, andstateis thenclean.apply-changesapplies only the service named in the path. A staged change on a sibling service in the same project is never carried along. It answers409when there is nothing staged, and needs anIdempotency-Key.- Poll the returned
deploymentIdthroughGET /deployments/{deploymentId}. It isnullwhen the apply reconfigured in place without a build, in which case re-read/changesuntilstateisclean.
POST /services/{serviceId}/actions/deploy-current-sourcebuilds the tip of the service's configured branch with its applied build configuration and environment. Use it after a build-only change — a new build type, Dockerfile path, or publish directory — which is stored immediately but does not deploy on its own. It never requires touching an unrelated setting to force a build.
Build the current branch tip
curl -sS --request POST "$NOUVA_API/services/$SERVICE_ID/actions/deploy-current-source" \
--header "Authorization: Bearer $NOUVA_API_TOKEN" \
--header "Idempotency-Key: $(uuidgen)"
# 202, Location: /v1/deployments/deployment_...
# { "deployment": { "id": "deployment_...", "status": "QUEUED", "commitSha": "..." } }- It resolves the branch tip at request time, so a service tracking
mainbuilds whatevermainpoints at now. Takesdeployments:write, and refuses a database service with a400. - It answers
409with detail codestaged_changes_pendingwhen the service has any staged change — variables, a volume, resources or a requested deletion — because a fresh build resolves the applied configuration and would quietly ship without them. Callapply-changesfirst. POST /services/{serviceId}/deploymentsis unchanged and still replays an existing deployment's source snapshot. Use it to rebuild a specific past commit; usedeploy-current-sourceto move forward.
POST /services/{serviceId}/actions/deploy-commit builds one explicitly named commit instead of the branch tip. A release pipeline that approved commit A must never ship commit B because the branch advanced before Nouva resolved it, so this action freezes the commit on the deployment the moment it is accepted and never substitutes the tip.
Deploy an approved commit and hand its identity to your release tooling
DEPLOYMENT=$(curl -sS --request POST "$NOUVA_API/services/$SERVICE_ID/actions/deploy-commit" \
--header "Authorization: Bearer $NOUVA_API_TOKEN" \
--header "Idempotency-Key: $(uuidgen)" \
--header "Content-Type: application/json" \
--data "{\"commitSha\":\"$GITHUB_SHA\"}")
# 202, Location: /v1/deployments/deployment_...
# { "deployment": { "id": "deployment_...", "status": "QUEUED", "commitSha": "<$GITHUB_SHA, lowercased>" } }
echo "$DEPLOYMENT" | jq -r '.deployment.id, .deployment.commitSha'commitShamust be the full 40-character SHA of a commit in the service's authorized repository. It does not have to be on the configured branch. An abbreviated SHA is refused with400, and a commit the repository does not have answers400with detail codecommit_not_foundon thecommitShafield.- The response carries the deployment id and the exact commit it will build, so external tooling can record the release against the image that actually ships. Retrying with the same
Idempotency-Keyreturns that same deployment rather than queuing a second one. - It takes the same guards as
deploy-current-source:409withstaged_changes_pendingwhile anything is staged, and400for a database service. Rollback and replaying a past deployment remain separate actions.
Deploy triggers and push previews
Every app and worker built from GitHub carries a deployTrigger in its source. push deploys the service whenever a push to its branch touches one of its watch paths; manual never deploys from a push, even when GitHub cannot say which files changed, and leaves releases to deploy-current-source, deploy-commit, or the dashboard. Set it and watchPaths when creating a service or with PATCH; neither change restarts anything. watchPaths is a list of repo-relative directories, or null to follow the build root, and the response reports the resolved list as effectiveWatchPaths (null means the whole repository).
POST /services/{serviceId}/actions/push-previewanswers "what would a push do?" before it happens. It runs the same decision the webhook handler runs against every service in the workspace on the same repository and branch, and reports each verdict with its reason. Name a commit and Nouva reads its changed files from GitHub, or hand it a list of paths to try a change you have not committed yet.
Preview which services a commit would deploy
curl -sS --request POST "$NOUVA_API/services/$SERVICE_ID/actions/push-preview" \
--header "Authorization: Bearer $NOUVA_API_TOKEN" \
--header "Content-Type: application/json" \
--data "{\"commitSha\":\"$GITHUB_SHA\"}"
# {
# "change": { "source": "commit", "commitSha": "...", "changedPaths": ["apps/api/src/server.ts"], "changedPathsTruncated": false },
# "repository": { "repository": "acme/app", "branch": "main" },
# "services": [
# { "id": "service_...", "name": "api", "type": "app", "projectId": "project_...",
# "deployTrigger": "push", "effectiveWatchPaths": ["apps/api"],
# "deploy": true, "reason": "path-matched", "matchedPaths": ["apps/api/src/server.ts"] },
# { "id": "service_...", "name": "jobs", "type": "worker", "projectId": "project_...",
# "deployTrigger": "manual", "effectiveWatchPaths": ["apps/api"],
# "deploy": false, "reason": "manual-trigger", "matchedPaths": [] }
# ]
# }
# Or try a change by hand: --data '{"changedPaths":["packages/shared/index.ts"]}'- Send exactly one of
commitSha(full 40-character SHA) orchangedPaths(up to 1000 paths). A commit the repository does not have answers400with detail codecommit_not_found, exactly asdeploy-commitdoes. reasonis one ofmanual-trigger,no-watch-paths,changed-paths-unknown,path-matched, andno-path-matched. Only the last one and a manual trigger skip a service; every uncertain case deploys, so a preview shows you what a push would do rather than what it might do.- The preview needs both
deployments:readandservices:read, because it names sibling services and their watch paths. A token limited to selected projects sees only the services in those projects, even though a real push also reaches the rest of the workspace. The preview is a read and needs noIdempotency-Key; it takes400for a database service. - A
commitShapreview reportsrepository.repositoryunder the name GitHub uses now, so a renamed repository shows its new name. AchangedPathspreview never asks GitHub and reports the name stored when the service was connected. - Previewing the same commit again is answered from memory and costs nothing. Previews of new commits read from GitHub, so they share an hourly budget per GitHub installation that keeps them from using up the requests your deploys need. Past it the preview answers
429rate_limitedwith agithub_preview_budget_exhausteddetail and aRetry-Afterheader, which can be up to an hour. TheX-RateLimit-*headers describe your token, not this budget.changedPathspreviews never touch GitHub and keep working.
GET /services/{serviceId}/push-decisionsanswers "why did my push not deploy?" after the fact. A skipped push creates no deployment, so this is where it shows up: each item is one push to the service's repository, newest first, with its commitSha, a verdict, the reason, the matchedPaths, and the deploymentId it created. It pages with cursor and limit like every other list. matchedPaths holds at most 20 paths; matchedPathCountis how many changed paths matched in all. Because each push shows the service's watch paths, the endpoint needs services:read as well as deployments:read.
reasonsays why the push was or was not chosen for the service;verdictsays what happened.deployedmeans the push created a deployment: it carries thedeploymentIdand that deployment's currentdeploymentStatus, which isFAILEDwhen the deployment could not start, and null once it is deleted.pendingis still being processed,skippedwas not chosen, andnot-deployedwas chosen but created no deployment, with acauseofsource-changed,source-incomplete, orprocessing-failed.reasonis one of the preview reasons above, or one ofinstallation-missing,installation-suspended,installation-deleted, andinstallation-unboundwhen the GitHub App installation could not deliver the push to your workspace at all.- Pushes are kept for 30 days. The list follows the service's current repository, so pushes to a repository it was connected to before are not listed.
Worker shutdown and rollouts
When a worker is redeployed, rolled back, or scaled down, the agent retires the old process gracefully. It sends the worker's shutdownSignal, waits up to shutdownGracePeriodSeconds for the process to exit, and then force-kills it with SIGKILL. Set these fields and rolloutPolicy when creating a worker or with PATCH. Each one is optional, and a field you leave out keeps its stored value. Changing them does not redeploy anything; they take effect on the next deploy. Sending any of them in a PATCH to an app returns 400 validation_failed naming the field.
Stop a scheduler before its replacement starts
curl -sS --request PATCH "$NOUVA_API/services/$SERVICE_ID" \
--header "Authorization: Bearer $NOUVA_API_TOKEN" \
--header "Content-Type: application/json" \
--data '{"shutdownSignal":"SIGTERM","shutdownGracePeriodSeconds":120,"rolloutPolicy":"no_overlap"}'
# "workerShutdown": { "signal": "SIGTERM", "gracePeriodSeconds": 120, "rolloutPolicy": "no_overlap" }shutdownSignalis one ofSIGTERM,SIGINT,SIGQUIT,SIGHUP,SIGUSR1, andSIGUSR2, ornull. It defaults tonull, which keeps the image's own stop signal: itsSTOPSIGNAL, orSIGTERMwhen it declares none. Sendnullin aPATCHto go back to the image's signal.shutdownGracePeriodSecondsis a whole number from 1 to 3600 and defaults to 30.rolloutPolicydefaults tooverlap, which starts the new worker before stopping the old one and suits queue consumers.no_overlapstops the old worker before starting the new one, so two copies never run at once. Use it for schedulers and singletons. It also applies to rollbacks and retries.- A worker with a volume always stops the old process first, whatever its
rolloutPolicysays, so two processes never write to the volume at once. - Every service response carries
workerShutdownwith the storedsignal,gracePeriodSeconds, androlloutPolicy. It isnullfor apps and databases, which have no worker process to retire. - A force-kill does not fail the rollout, because the new worker is already running. The deployment still reaches
LIVEand itsstatusMessagereports which process was force-killed, since an in-flight job may have been interrupted. - An older server agent cannot apply these settings. On a server that has not been updated, changing any of them answers
412withprecondition_failed, while sending the stored values unchanged still succeeds.GET /servers/{serverId}/capabilitiesreportsworkerShutdownPolicyV1once the agent supports them.
Resource limits and swap
resourceLimitssets a service's CPU, memory, and swap when creating it or with PATCH. memoryAndSwapBytesis the combined RAM and swap ceiling, the same unit as Docker's MemorySwap, so 512 MiB of memory with 512 MiB of swap is 1073741824. An app or worker redeploys with the new limits; a database stages them until you apply its changes.
Give a service 512 MiB of memory and 512 MiB of swap
curl -sS --request PATCH "$NOUVA_API/services/$SERVICE_ID" \
--header "Authorization: Bearer $NOUVA_API_TOKEN" \
--header "Content-Type: application/json" \
--data '{"resourceLimits":{"cpuMillicores":500,"memoryBytes":536870912,"memoryAndSwapBytes":1073741824}}'
# "cpuLimitMillicores": 500, "memoryLimitBytes": "536870912", "memoryAndSwapLimitBytes": "1073741824"resourceLimitsreplaces the whole set. A limit you leave out goes back to the protected default, and leaving outmemoryAndSwapBytesremoves the swap allowance. Send all three to change one.memoryAndSwapBytesmust be at least the memory limit and at most twice it, so a service gets at most as much swap as memory. WithoutmemoryBytesit is measured against the default memory limit. Outside that range the request answers400validation_failedwith amemory_and_swap_below_memory_limitormemory_and_swap_above_maximumdetail naming the numbers.- Only memory counts against server capacity, not swap. An allowance only helps when the host itself has swap configured.
- Service responses carry
memoryAndSwapLimitBytes, and/changesreportsmemoryAndSwapBytesunderresources.activeandresources.desired. Without swap it equals the memory limit. - Build containers keep a fixed limit derived from the server's size and never get swap;
resourceLimitsapplies to the running service only.
GitHub sources and setup handoff
Creating an app or worker needs the numeric installationId and repoId of a GitHub installation already connected to your workspace. GitHub access on its own is not enough, so discover both from the API rather than guessing them. These routes need sources:read; they read workspace-level connections and are not narrowed by a token's project reach.
Discover a source, then create a service
SOURCES=$(curl -sS "$NOUVA_API/github/installations" \
--header "Authorization: Bearer $NOUVA_API_TOKEN")
if [ "$(echo "$SOURCES" | jq -r '.setup.state')" != "connected" ]; then
echo "Connect GitHub at $(echo "$SOURCES" | jq -r '.setup.url'), then re-run."
exit 1
fi
INSTALLATION_ID=$(echo "$SOURCES" | jq -r '.installations[] | select(.state == "active") | .installationId' | head -n 1)
REPO=$(curl -sS "$NOUVA_API/github/installations/$INSTALLATION_ID/repositories?limit=100" \
--header "Authorization: Bearer $NOUVA_API_TOKEN" \
| jq '.items[] | select(.fullName == "acme/storefront")')
curl -sS --request POST "$NOUVA_API/projects/$PROJECT_ID/services" \
--header "Authorization: Bearer $NOUVA_API_TOKEN" \
--header "Idempotency-Key: $CI_KEY-service" \
--header "Content-Type: application/json" \
--data "{
\"name\": \"storefront\",
\"type\": \"app\",
\"installationId\": $INSTALLATION_ID,
\"repoId\": $(echo "$REPO" | jq '.id'),
\"repoFullName\": $(echo "$REPO" | jq '.fullName'),
\"branch\": $(echo "$REPO" | jq '.defaultBranch')
}"setup.stateisconnectedwhen at least one installation can authorize a build,reauthorization_requiredwhen every connected installation was suspended or removed on GitHub, andsetup_requiredwhen the workspace has never connected one.setup.urlis the dashboard screen that completes it.- Installing or reauthorizing a GitHub App needs a human in a browser. Send someone to
setup.url, then pollGET /github/installationsand resume; nothing about the API sequence changes once the state turnsconnected. - Repository listing follows the usual
limit,cursor,nextCursor, andhasMorepagination, and returns only the repositories that installation was granted. A repository outside the installation's selection answers404, never a listing of unrelated private repositories. - A connected installation that GitHub has since suspended or removed answers
412 precondition_failed. Installation tokens and OAuth tokens are never part of any response.
Responses, headers, and errors
Nouva never runs your workloads directly, so most mutations return 202 and queue work for the agent on your server. Poll the resource until it reaches a terminal state instead of assuming the response body is final.
| Status | When you see it |
|---|---|
200 | A read, or a mutation that completed inside the control plane. |
201 | A record was created and needs no agent work. |
202 | Work was queued for your server. Poll the resource for the outcome. |
204 | The delete was accepted and there is no body. A project delete still finishes on your server. |
Every response includes a server-generated X-Request-Id. Keep that ID when contacting support or correlating API activity. Asynchronous responses usually also carry a Location header pointing at the resource to poll. Errors use one stable envelope.
Error response
{
"error": {
"code": "stable_snake_case_code",
"message": "Safe actionable message",
"requestId": "00000000-0000-4000-8000-000000000000",
"details": []
}
}| Error code | Status | Meaning |
|---|---|---|
authentication_required | 401 | No bearer token was supplied. |
invalid_token | 401 | The token is malformed or not recognized. |
token_expired | 401 | The token passed its expiry date. |
token_revoked | 401 | The token was revoked or rotated away. |
token_owner_disabled | 401 | The owning user is no longer a workspace admin. |
insufficient_scope | 403 | The token lacks the required scope. |
permission_denied | 403 | The token owner may not perform this action on this resource. Scopes are fine. |
project_not_allowed | 403 | The project is outside the token's project reach. |
github_installation_not_bound | 403 | The GitHub App installation is not connected to this workspace. |
not_found | 404 | The resource does not exist, or belongs to another workspace. |
validation_failed | 400 | The request did not match the contract. |
idempotency_key_required | 400 | A POST route was called without an Idempotency-Key. |
conflict | 409 | The resource conflicts with existing state. |
idempotency_conflict | 409 | The key was reused with a different payload, or is still in flight. |
precondition_failed | 412 or 413 | The resource is not in a state that allows the operation. |
unresolved_reference | 412 | A referenced service or variable could not be resolved. |
insufficient_capacity | 412 | Your server cannot accommodate the requested CPU or memory. |
rate_limited | 429 | A rate-limit window was exhausted. |
request_timeout | 408 | An upload stopped sending data before it finished. Send it again. |
dependency_unavailable | 503 | A required Nouva dependency was unavailable. |
internal_error | 500 | An unexpected error. Report the request ID. |
The code names the recovery action, so a caller never has to guess between rotating a token, connecting GitHub, repairing a reference, and asking for less capacity. When a failure has a specific cause, details carries it: the missing scope, the reference category that failed, what still blocks a project delete, the conflicting resource name, or the requested and accepted CPU and memory amounts. New code and details[].code values may be added within v1, so treat an unrecognized one as its HTTP status.
Resources outside your token workspace return 404 rather than 403, so probing IDs cannot confirm that they exist. Error messages are deliberately generic and never echo credentials or provider diagnostics.
Pagination and idempotency
Collection endpoints use versioned keyset cursors based on creation time and ID. The default page size is 20 and the maximum is 100. Pass the previous nextCursor back as ?cursor= to walk a collection; a cursor from a different collection is rejected with 400.
Paginated response
{
"items": [],
"nextCursor": "opaque-versioned-cursor",
"hasMore": false
}Every POST route requires an Idempotency-Key header of at most 255 characters. Reuse the same key only for the same request. Matching completed requests replay safely; a different payload or a concurrent in-flight reuse returns 409. Keys are retained for 24 hours. PATCH and DELETE routes do not take the header.
Multipart bucket uploads also require a lowercase SHA-256 contentSha256 query parameter. Compute it from the file bytes before upload. It is part of the idempotency fingerprint, so a changed artifact cannot replay an earlier upload. Uploads carry one file per request and are capped at 1 GiB by default.
Upload one bucket object
FILE="./artifact.tar.gz"
CONTENT_SHA256="$(shasum -a 256 "$FILE" | awk '{print $1}')"
curl --fail-with-body --request POST \
"$NOUVA_API_BASE/buckets/$NOUVA_BUCKET_ID/objects/upload?environmentId=$NOUVA_ENVIRONMENT_ID&key=artifacts/artifact.tar.gz&contentSha256=$CONTENT_SHA256" \
--header "Authorization: Bearer $NOUVA_API_TOKEN" \
--header "Idempotency-Key: $CI_KEY-upload" \
--form "file=@$FILE"Run a deployment from CI
set -euo pipefail
export NOUVA_API_TOKEN="${NOUVA_API_TOKEN:?Set a CI secret}"
export NOUVA_API_BASE="https://api.nouva.sh/v1"
export NOUVA_PROJECT_NAME="${NOUVA_PROJECT_NAME:-ci-project}"
export NOUVA_SERVICE_NAME="${NOUVA_SERVICE_NAME:-ci-app}"
request() {
curl --fail-with-body --silent --show-error \
--header "Authorization: Bearer $NOUVA_API_TOKEN" "$@"
}
CI_KEY="ci-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
PROJECT_ID="${NOUVA_PROJECT_ID:-}"
# Select the requested project, or create it once with a CI-derived key.
if [ -z "$PROJECT_ID" ]; then
PROJECT_ID="$(request "$NOUVA_API_BASE/projects?limit=100" | \
jq -r --arg name "$NOUVA_PROJECT_NAME" '.items[] | select(.name == $name) | .id' | head -n 1)"
fi
if [ -z "$PROJECT_ID" ]; then
PROJECT_BODY="$(jq -nc --arg name "$NOUVA_PROJECT_NAME" '{name: $name}')"
PROJECT_ID="$(request --request POST "$NOUVA_API_BASE/projects" \
--header "Idempotency-Key: $CI_KEY-project" \
--header "Content-Type: application/json" \
--data "$PROJECT_BODY" | jq -er '.project.id')"
fi
# Select the service, or create an app from the connected GitHub installation.
SERVICE_ID="$(request "$NOUVA_API_BASE/projects/$PROJECT_ID/services?limit=100" | \
jq -r --arg name "$NOUVA_SERVICE_NAME" '.items[] | select(.name == $name) | .id' | head -n 1)"
if [ -z "$SERVICE_ID" ]; then
: "${NOUVA_GITHUB_INSTALLATION_ID:?Set the connected GitHub installation ID}"
: "${NOUVA_GITHUB_REPOSITORY_ID:?Set the GitHub repository ID}"
SERVICE_BODY="$(jq -nc \
--arg name "$NOUVA_SERVICE_NAME" \
--argjson installationId "$NOUVA_GITHUB_INSTALLATION_ID" \
--argjson repoId "$NOUVA_GITHUB_REPOSITORY_ID" \
'{type: "app", name: $name, installationId: $installationId, repoId: $repoId,
branch: "main", appBuildType: "railpack", appBuildConfig: {buildRoot: "."},
resourceLimits: null, storageSizeGb: 0}')"
CREATE_RESPONSE="$(request --request POST "$NOUVA_API_BASE/projects/$PROJECT_ID/services" \
--header "Idempotency-Key: $CI_KEY-service" \
--header "Content-Type: application/json" \
--data "$SERVICE_BODY")"
SERVICE_ID="$(printf %s "$CREATE_RESPONSE" | jq -er '.service.id')"
DEPLOYMENT_ID="$(printf %s "$CREATE_RESPONSE" | jq -er '.deployment.id')"
else
DEPLOYMENT_ID="$(request --request POST "$NOUVA_API_BASE/services/$SERVICE_ID/deployments" \
--header "Idempotency-Key: $CI_KEY-deployment" \
--header "Content-Type: application/json" \
--data '{}' | jq -er '.deployment.id')"
fi
# Poll a terminal deployment state, then fetch a bounded runtime-log page.
for attempt in $(seq 1 60); do
DEPLOYMENT="$(request "$NOUVA_API_BASE/deployments/$DEPLOYMENT_ID")"
STATUS="$(printf %s "$DEPLOYMENT" | jq -er '.deployment.status')"
case "$STATUS" in
LIVE) break ;;
FAILED|REPLACED|REMOVED) printf '%s\n' "$DEPLOYMENT" >&2; exit 1 ;;
esac
sleep 5
done
if [ "$STATUS" != "LIVE" ]; then
printf 'Deployment did not reach LIVE before timeout (last status: %s)\n' "$STATUS" >&2
exit 1
fi
request "$NOUVA_API_BASE/deployments/$DEPLOYMENT_ID/logs/runtime?limit=200&mode=initial&type=all" | jq .Logs and metrics
Logs
- Runtime and worker-job log routes accept
limit(1 to 500, default 200),mode(initialorolder),type(stdout,stderr, orall), andcursor. - Page backwards by passing the returned
nextCursorwithmode=older. - Build logs return the last 500 entries and are not paginated.
- Runtime logs are retained for seven days.
- Values that match your service or deployment environment variables are redacted.
Metrics
- Metric routes accept
rangeof1h,6h,1d,7d, or30d, defaulting to1h. - Worker services also accept
replicaIndexto scope the series to one continuous replica. - Responses carry a
statusofready,pending,stale, orunavailablealongside a human-readablemessage, so treat a missing series as a normal outcome. - Metrics are retained for 30 days.
Rate limits and compatibility
Rate limits
- 120 requests per minute per token.
- 600 requests per minute per workspace.
- 30 invalid authentication attempts per minute per trusted client IP.
- Rate-limit headers are returned with responses.
Compatibility
- Changes within
/v1are backward-compatible and additive. - Breaking changes require a new version such as
/v2. - Nouva documents deprecations at least 90 days before removal.
| Header prefix | Window |
|---|---|
X-RateLimit- | Per token. |
X-Workspace-RateLimit- | Per workspace. |
X-Invalid-Auth-RateLimit- | Per client IP, returned on invalid-authentication responses. |
Each prefix carries Limit, Remaining, and Reset, where Reset is a Unix timestamp in seconds. If the API rate-limit store is unavailable, requests fail closed with 503.