Docs navigation

API

API reference

Generated from the OpenAPI specification Lunary Cloud publishes (v1.0.0). Also browsable in Swagger UI.

The customer-facing REST API for Lunary Cloud.

Authentication. Every endpoint requires a bearer token minted from the dashboard (Access → API tokens): Authorization: Bearer <token>. A token is scoped to one customer and carries scopes — read_instances, write_instances, read_plugs, write_plugs, read_billing, push_images — and an optional expiry, so a read-only token can be issued for an agent or CI job.

Identifiers. Instances are addressed by slug (unique per customer), runs by an integer id (int64). Cursors and artifact keys are opaque — echo them back verbatim.

Errors. Every failure returns the envelope { "error": "<code>" }, with an optional details object on some codes. Validation failures are 422 invalid with details.fields mapping each field to its problems. The code vocabulary is closed and additive: new codes may appear, existing ones won't be renamed. Common everywhere: 401 unauthorized (bad/expired token), 403 insufficient_scope (valid token, missing scope — details. required names it), 404 not_found, 429 rate_limited, and 400 malformed_json (unparseable body).

Pagination. List endpoints return { "data": [...], "meta": { "next_cursor": <opaque|null>, "limit": <n> } }. Page by echoing next_cursor back as ?after=; a null cursor is the last page.

Rate limits. Budgets are per customer and per class — reads and writes are metered separately, with writes stricter. Responses carry X-RateLimit-Limit / X-RateLimit-Remaining for the class they drew from; a 429 includes Retry-After (seconds).

Async writes. Provisioning, teardown, and plug runs are asynchronous. Teardown and plug runs answer 202 with data.run_id and a Location pointing at the run; provisioning answers 201 with Location pointing at the created instance and the apply run in meta.run_id. Either way, poll GET /runs/{run_id} for the outcome.

Enums. Fields documented with an enum keyword (managed_by, environment, health.status) are closed sets. Lifecycle fields (run status, source, trigger, billing state) are open sets — their current values are listed in each description, new values may appear over time, and clients should treat unknown values as "other" rather than failing. The documented terminal set for runs is stable.

Idempotency. The Idempotency-Key header is reserved for write endpoints but not yet honored — today it is ignored, so do not rely on it for retry safety yet. Retry guidance until then: POST /instances answers a retried create with 409 slug_taken (treat as "the first request succeeded" and GET /instances/{slug}).

Billing

GET /api/v1/billing/invoices List invoices

Requires the read_billing scope. Reads the local projection of Stripe webhook events (no live Stripe call), newest first. Cursor-paged.

Parameters

limit query · integer

Page size (default 25, max 100).

after query · string

Opaque cursor from a prior meta.next_cursor.

Responses

  • 200 A page of invoices BillingInvoiceList
  • 400 Malformed cursor Error
  • 401 Missing/invalid token Error
  • 403 Token lacks read_billing Error
GET /api/v1/billing/payment-method Get the default payment method

Requires the read_billing scope. Served from Stripe-mirrored state; all fields are null until a payment method is on file.

Responses

  • 200 The payment method BillingPaymentMethodResponse
  • 401 Missing/invalid token Error
  • 403 Token lacks read_billing Error
GET /api/v1/billing/subscription Get the subscription

Requires the read_billing scope. Served from Stripe-mirrored state — current plan, status, and renewal/cancellation dates, with no live Stripe call.

Responses

  • 200 The subscription BillingSubscriptionResponse
  • 401 Missing/invalid token Error
  • 403 Token lacks read_billing Error

Catalog

GET /api/v1/catalog List what you can deploy

Requires the read_instances scope. Every entry can be passed as catalog_slug to POST /instances: the catalog apps open to your organization (kind template, with the deploy-time required_secrets each needs) and custom, an image you supply (its limit_presets are ready-made custom.limits). An app you can't deploy self-serve is absent, not marked. Not paged.

Responses

  • 200 The deployable catalog CatalogList

Domains

GET /api/v1/domains List domains

The customer's domains that are not released, each with its TXT record and the hostnames under it. Requires the read_instances scope.

Responses

  • 200 Domains DomainList
POST /api/v1/domains Add a domain

Starts proving ownership of a domain you control. The response carries the TXT record to publish (verification); checks run every few minutes for 48 hours, or on demand via POST /domains/{name}/verify. Hostnames under it can be added right away and start serving once it verifies. Never a name under lunary.cloud (422 reserved_zone). A domain already on this list is 409 already_added; one another organization holds verified is 409 domain_taken. Requires the write_instances scope.

Request body

AddDomainRequest required (application/json) · Domain to add

Responses

  • 201 The domain, pending its TXT record DomainResponse
  • 409 `already_added` or `domain_taken` Error
  • 422 `invalid`, `invalid_domain`, or `reserved_zone` Error
DELETE /api/v1/domains/{name} Remove a domain

Releases the domain and every hostname under it: they stop being served on the next route push, and the names are free to add again (with a fresh proof). Requires the write_instances scope.

Parameters

name required path · string

The domain.

Responses

  • 204 Released
  • 404 No such domain for this customer Error
GET /api/v1/domains/{name} Get one domain

Requires the read_instances scope.

Parameters

name required path · string

The domain.

Responses

  • 200 The domain DomainResponse
  • 404 No such domain for this customer Error
POST /api/v1/domains/{name}/verify Verify a domain now

Checks the domain's TXT record against two public resolvers right now and returns the domain with the outcome in state and last_check — synchronous, so a client can act on the answer (a few seconds at most). Also the way to resume a domain whose 48-hour verification window lapsed. Requires the write_instances scope.

Parameters

name required path · string

The domain.

Responses

  • 200 The domain after the check DomainResponse
  • 404 No such domain for this customer Error

Instances

GET /api/v1/instances List instances

Requires the read_instances scope. Cursor-paged.

Parameters

limit query · integer

Page size (default 25, max 100).

after query · string

Opaque cursor from a prior meta.next_cursor.

Responses

  • 200 A page of instances InstanceList
  • 400 Malformed cursor Error
  • 401 Missing/invalid token Error
  • 403 Token lacks read_instances Error
POST /api/v1/instances Provision an instance

Requires the write_instances scope. Quota-gated; the deploy spec is built from the catalog preset server-side; catalog_slug custom runs an image you supply, described by the typed custom object (never a compose file). Provisioning is async — the response's meta.run_id is the apply run to poll via GET /runs/{run_id}. A slug that already exists returns 409 slug_taken: on a retried timeout, treat that as "the first request succeeded" and GET /instances/{slug}. The instance is born on its conventional address, at the exposure Lunary resolves for the app — gated (behind your Lunary login) for an app that is claimable by the first visitor or has no login of its own, else public — where Lunary renders your routes, else internal until Lunary enables it after deploy; public and domain are rejected (422 not_settable). Refused while the customer's VM is offline (409 tether_offline) — nothing is provisioned. The body is validated before the VM is checked, so a malformed request is 422 even while the VM is offline. Every error carries a details.message; slug_taken also carries details.slug, invalid per-field details.fields.

Request body

CreateInstanceRequest required (application/json) · Instance to provision

Responses

  • 201 The provisioned instance InstanceCreateResponse
  • 409 `slug_taken`, `tether_offline`, `retained_app_mismatch`, `retained_database_mismatch`, or `undecryptable_retained_data` Error
  • 422 `not_settable`, `over_budget`, `not_provisionable`, `missing_catalog_slug`, `missing_secrets`, `no_free_port`, `invalid`, `invalid_source`, `invalid_sync_cadence`, or `operation_failed` Error
DELETE /api/v1/instances/{slug} Tear down an instance

Requires the write_instances scope. Only operator-managed instances can be torn down; the teardown runs async, so a run id is returned — poll it via GET /runs/{run_id}.

Parameters

slug required path · string

Instance slug.

remove_volumes query · boolean

Purge the instance's data instead of retaining it (default false).

Responses

  • 202 Teardown enqueued RunAccepted
  • 404 No such instance for this customer Error
  • 422 Not operator-managed, or invalid Error
GET /api/v1/instances/{slug} Get one instance

Requires the read_instances scope.

Parameters

slug required path · string

Instance slug.

Responses

  • 200 The instance InstanceResponse
  • 404 No such instance for this customer Error
PATCH /api/v1/instances/{slug} Update an instance's image, or a custom app's configuration

Requires the write_instances scope. Takes image, env, secrets, healthcheck, limits and volumes, in any combination, and nothing else — any other field is 422 not_settable. env replaces a custom instance's whole non-secret environment; secrets merges by name: a string sets or replaces the stored value, null removes the name, a name not mentioned keeps its value (values are never returned). healthcheck replaces the HTTP check: an object with path sets it, null removes it so the image's own HEALTHCHECK applies. limits replaces the caps whole: cpus and/or memory_mib, null or {} lifts them; a change that raises what the instance costs is priced against your plan on the dimensions it raises (422 over_budget, with details.dimensions), lowering never is. volumes is the whole list, add-only: every declared volume stays with its name and mount (422 invalid naming it otherwise), new ones join it, and an instance declaring its first volume gains the volume snapshot and its daily routine. A field not sent keeps what it has. Every declared secret must hold a value once the changes land (422 missing_secrets, with details.keys); a catalog app's configuration belongs to its preset (422 not_custom). An image reference is validated like custom.image at provisioning (pinned to a tag or digest, on a valid registry host) and stored as given: a tag is re-resolved on every redeploy, a digest is not. A custom instance takes any repository; the rest of its spec is carried over. A catalog app takes only a new tag or digest of the repository the catalog pins (422 repository_mismatch otherwise, with details.repository); its secondary images stay as pinned, and an entry pinned to a tag variant keeps it (422 variant_mismatch). The compose file is re-rendered server-side either way. Store and redeploy commit together; the redeploy is async, so meta.run_id is the run to poll via GET /runs/{run_id}. Refused while a redeploy of this instance is still running (409 redeploy_in_flight, with details.run_id), while the instance's redeploy plug is switched off (409 plug_disabled), and when the new image's registry needs a credential the customer hasn't registered (422 no_registry_credential) — the current image's registry has one, the new one doesn't; the Lunary registry never needs one.

Parameters

slug required path · string

Instance slug.

Request body

UpdateInstanceImageRequest required (application/json) · The changes

Responses

  • 202 The updated instance; redeploy enqueued InstanceCreateResponse
  • 404 No such instance for this customer Error
  • 409 `redeploy_in_flight` or `plug_disabled` Error
  • 422 `not_settable`, `invalid`, `missing_secrets`, `not_custom`, `over_budget`, `repository_mismatch`, `variant_mismatch`, `not_operator_managed`, `no_registry_credential`, or `operation_failed` Error
PUT /api/v1/instances/{slug}/address Rename an instance's Lunary address

Requires the write_instances scope. Moves a self-serve instance to a new address under your zone — <label>.<org>.lunary.cloud, lowercase letters, digits and hyphens — and restarts it on the new name: the app's own URL settings (n8n's host, Twenty's server URL, Docmost's app URL, Ghost's site URL) follow, so meta.run_id is the apply run to poll. The old name stops answering; custom hostnames pointing at it keep working. 200 with a null meta.run_id when the address already was the one asked for. Refused for instances Lunary manages declaratively (422 not_operator_managed, not_renamable), for a name outside your zone (422 invalid_address), one another instance answers on (409 address_taken), one that doesn't resolve (422 address_unresolvable), or while a redeploy is running (409 redeploy_in_flight).

Parameters

slug required path · string

Instance slug.

Request body

SetAddressRequest required (application/json) · The new address

Responses

  • 200 Already at that address (`meta.run_id` null) InstanceAddressResponse
  • 202 The renamed instance; restart enqueued InstanceAddressResponse
  • 404 No such instance for this customer Error
  • 409 `address_taken` or `redeploy_in_flight` Error
  • 422 `invalid`, `invalid_address`, `address_unresolvable`, `not_operator_managed`, `not_renamable`, or `operation_failed` Error

Hostnames

GET /api/v1/instances/{instance_slug}/hostnames List an instance's hostnames

Live hostnames mapped onto the instance, each with the DNS record it needs. Requires the read_instances scope.

Parameters

instance_slug required path · string

Instance slug.

Responses

  • 200 Hostnames HostnameList
  • 404 No such instance for this customer Error
POST /api/v1/instances/{instance_slug}/hostnames Map a hostname onto an instance

Adds a hostname under one of your domains (added via POST /domains; it may still be pending) and returns the DNS record to publish (record): for a subdomain a CNAME, the hostname → the instance's address; for the domain itself (its apex) A records carrying the addresses that instance address resolves to. The hostname serves once the record points and the domain is verified; the certificate issues on the first request. The instance must be public (422 instance_not_public); a hostname under no domain of yours is 422 no_domain; one already mapped anywhere is 409 hostname_taken. Requires the write_instances scope.

Parameters

instance_slug required path · string

Instance slug.

Request body

AddHostnameRequest required (application/json) · Hostname to map

Responses

  • 201 The hostname, pending its record HostnameResponse
  • 404 No such instance for this customer Error
  • 409 `hostname_taken` Error
  • 422 `invalid`, `invalid_hostname`, `reserved_zone`, `no_domain`, or `instance_not_public` Error
DELETE /api/v1/instances/{instance_slug}/hostnames/{hostname} Remove a hostname

Releases the hostname: it stops being served on the next route push and can be mapped again later. Requires the write_instances scope.

Parameters

instance_slug required path · string

Instance slug.

hostname required path · string

The hostname.

Responses

  • 204 Released
  • 404 No such instance or hostname for this customer Error
GET /api/v1/instances/{instance_slug}/hostnames/{hostname} Get one hostname

Requires the read_instances scope.

Parameters

instance_slug required path · string

Instance slug.

hostname required path · string

The hostname.

Responses

  • 200 The hostname HostnameResponse
  • 404 No such instance or hostname for this customer Error
POST /api/v1/instances/{instance_slug}/hostnames/{hostname}/verify Verify a hostname now

Checks the hostname's record against two public resolvers right now and returns it with the outcome in state and last_check — synchronous. last_check flattened means a subdomain resolves to an address instead of a CNAME (a proxied record): switch it to DNS-only. aaaa means the domain itself also has an AAAA record: delete it. Requires the write_instances scope.

Parameters

instance_slug required path · string

Instance slug.

hostname required path · string

The hostname.

Responses

  • 200 The hostname after the check HostnameResponse
  • 404 No such instance or hostname for this customer Error

Plugs

GET /api/v1/instances/{instance_slug}/plugs List an instance's plugs

What can be run on this instance: configured (plugs configured on it) and catalog — every customer-facing plug its app supports, with its summary, required params, and whether it is dangerous, supports a dry run, produces artifacts, or can be scheduled. The response is always complete — the set is bounded by the app's plug catalog, so it is never paginated. Internal lifecycle plugs are never listed. Requires the read_plugs scope.

Parameters

instance_slug required path · string

Instance slug.

Responses

  • 200 Configured and available plugs PlugsResponse
  • 404 No such instance for this customer Error
POST /api/v1/instances/{instance_slug}/plugs/{plug}/runs Run a plug

Creates a run of this plug and returns its id — poll it via GET /runs/{run_id}. A plug the customer may configure but hasn't is configured on first run, so this is always one step; a plug that was explicitly disabled returns 409. Optionally takes params (a one-off override of the stored configuration — the stored row is not modified) and dry_run. 404 means an unknown or unowned instance, or a plug that isn't customer-triggerable. Requires the write_plugs scope.

Parameters

instance_slug required path · string

Instance slug.

plug required path · string

The plug to run.

Request body

TriggerPlugRequest (application/json) · Trigger options

Responses

  • 202 Run enqueued RunAccepted
  • 404 Unowned instance, or a plug that isn't customer-triggerable Error
  • 409 The plug is disabled on this instance Error
  • 422 Invalid plug parameters, a dry run the plug doesn't support, or a backup/snapshot refused because the plan's artifact allowance is used up (`over_budget`) Error

Registries

GET /api/v1/registries List registry credentials

The caller's private-registry credentials — host and username only; the token is never returned. Requires the read_instances scope.

Responses

  • 200 Registry credentials RegistryCredentialList
POST /api/v1/registries Store a registry credential

Stores a pull credential for a private image registry, encrypted at rest. It is released only to your own VM while an instance whose image lives on that host is deployed or redeployed. One per host: a second for the same host is 409 host_taken — delete the first to rotate. Requires the write_instances scope.

Request body

CreateRegistryCredentialRequest required (application/json) · Credential to store

Responses

  • 201 The stored credential RegistryCredentialResponse
  • 409 `host_taken` Error
  • 422 `invalid` (per-field `details.fields`), or `operation_failed` when it could not be stored Error
DELETE /api/v1/registries/{id} Delete a registry credential

Removes the credential. Instances already running keep their pulled image; the next deploy or redeploy of an instance on that host fails until a new credential is stored. Requires the write_instances scope.

Parameters

id required path · integer

Responses

  • 204 Deleted
  • 404 No such credential for this customer Error

Lunary registry

GET /api/v1/registry Describe the Lunary registry

The container registry Lunary hosts for your own images: whether it is enabled, its host, your namespace (your organization slug), the prefix to tag pushes with, and what docker login needs. Push with an API token carrying push_images as the password, pull from outside Lunary with one carrying pull_images; an instance whose image is on the registry deploys with no stored credential. Always 200: where the registry is not offered, enabled is false and the other fields are null. Requires the read_instances scope.

Responses

  • 200 The registry LunaryRegistryResponse
GET /api/v1/registry/images List images on the Lunary registry

Every repository in your namespace on the Lunary registry, with its tags and the digest each tag points at, read from the registry as you call. Bounded and never paginated. 503 registry_disabled where the registry is not offered; 503 registry_unavailable when it did not answer (details.message says which). Requires the read_instances scope.

Responses

  • 200 The customer's images RegistryImageList
  • 503 `registry_disabled` or `registry_unavailable` Error
DELETE /api/v1/registry/images/{name}/tags/{tag} Delete an image tag

Removes tag from the image name in your namespace. The image itself stays until the nightly sweep finds it untagged, so a digest another tag still points at is unaffected, and a digest-pinned deploy survives only while some tag points at it. 409 image_in_use while an instance deploys from the tag, or from the digest it points at (details.instances names them): move them first. 404 not_found when the repository or tag does not exist; 503 as the images list. Requires the push_images scope.

Parameters

name required path · string

The image name inside your namespace, as the images list gives it.

tag required path · string

Responses

  • 204 Deleted
  • 404 No such image or tag Error
  • 409 `image_in_use` Error
  • 503 `registry_disabled` or `registry_unavailable` Error

Routines

GET /api/v1/routines List routines

All of the caller's routines, each naming its workflow, its target instance, its cadence (cron is the exact expression, UTC), and the last thing the scheduler did with it. Bounded by the per-customer routine limit (50), so it is never paginated. Requires the read_plugs scope.

Parameters

instance query · string

Only routines targeting this instance slug.

Responses

  • 200 Routines RoutineList
POST /api/v1/routines Create a routine

Binds a stock workflow to an instance, optionally on a cadence. cadence is one of on_demand/hourly/daily/weekly/monthly/custom; custom takes the exact cron expression (UTC). A workflow whose plug requires confirmation to repeat unattended (restore, redeploy) needs acknowledge: true — the acknowledgement is recorded against the calling token, never a caller-supplied name. Refused with routine_limit at the per-customer cap (50). Requires the write_plugs scope.

Request body

CreateRoutineRequest (application/json) · The routine

Responses

  • 201 Created RoutineResponse
  • 409 The workflow's plug is disabled on the target Error
  • 422 Refused — see the `error` code Error
DELETE /api/v1/routines/{id} Delete a routine

Parameters

id required path · integer

Responses

  • 204 Deleted
  • 404 No such routine for this customer Error
GET /api/v1/routines/{id} Get a routine

Parameters

id required path · integer

Responses

  • 200 The routine RoutineResponse
  • 404 No such routine for this customer Error
PATCH /api/v1/routines/{id} Update a routine

Changes any of name, cadence/cron, params, overlap, enabled (the customer's own switch), and instance — which retargets the routine. Changing the workflow or params clears a recorded acknowledgement unless the same call carries acknowledge: true. Requires the write_plugs scope.

Parameters

id required path · integer

Request body

UpdateRoutineRequest (application/json) · Fields to change

Responses

  • 200 Updated RoutineResponse
  • 404 No such routine for this customer Error
  • 409 The workflow's plug is disabled on the target Error
  • 422 Refused — see the `error` code Error
POST /api/v1/routines/{id}/resume Resume a paused routine

Clears a system pause (paused_reason). The customer's own enabled switch is separate — flip it via PATCH. Refused while the workflow's plug is switched off: turning the plug back on is what resumes those. Requires the write_plugs scope.

Parameters

id required path · integer

Responses

  • 200 Resumed RoutineResponse
  • 404 No such routine for this customer Error
  • 409 The workflow's plug is disabled on the target Error
POST /api/v1/routines/{id}/runs Run a routine now

Creates a run of the routine's workflow against its target and returns the run id — poll it via GET /runs/{run_id}. Requires the write_plugs scope.

Parameters

id required path · integer

Responses

  • 202 Run enqueued RunAccepted
  • 404 No such routine for this customer Error
  • 409 The workflow's plug is disabled on the target Error

Runs

GET /api/v1/runs List runs

Recent runs, newest first. Requires the read_plugs scope. Cursor-paged.

Parameters

instance_slug query · string

Narrow to one instance's runs.

limit query · integer

Page size (default 25, max 100).

after query · string

Opaque cursor from a prior meta.next_cursor.

Responses

  • 200 A page of runs RunList
  • 400 Malformed cursor Error
GET /api/v1/runs/{id} Get one run

The run with what it did: steps and artifacts. Pass ?include=context for the plug's raw threaded result — it is opt-in because it is sensitive: a plug may deliberately return a secret there (reset_admin_password returns the new password once), and its shape is plug-defined with no compatibility guarantee. Requires the read_plugs scope for any run; write_instances alone reads the runs the instance writes return (an apply, redeploy or teardown), so a provisioning token can poll what it started.

Parameters

id required path · integer

Run id.

include query · string

Comma-separated extra fields to include. Supported: context. Unknown values are a 400 invalid_include.

Responses

  • 200 The run RunResponse
  • 400 Unknown include value Error
  • 403 A write_instances-only token asked for a run outside its lifecycle writes Error
  • 404 No such run for this customer Error
GET /api/v1/runs/{run_id}/artifacts/download Get a download URL for a run's artifact

A short-lived presigned URL for one artifact the run recorded. The key must be one this run actually produced — anything else reads as 404, including another customer's run. Requires the read_plugs scope.

Parameters

run_id required path · integer

Run id.

key required query · string

The artifact's object key, as returned by GET /runs/:id.

Responses

  • 200 A time-limited download URL ArtifactDownloadResponse
  • 404 No such artifact on a run of this customer Error
  • 503 The artifact store isn't configured Error