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
/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 |
Responses
-
200
A page of invoices
BillingInvoiceList -
400
Malformed cursor
Error -
401
Missing/invalid token
Error -
403
Token lacks read_billing
Error
/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
/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
/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
/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
/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
/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
/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
/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
/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 |
Responses
-
200
A page of instances
InstanceList -
400
Malformed cursor
Error -
401
Missing/invalid token
Error -
403
Token lacks read_instances
Error
/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
/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
/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
/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
/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
/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
/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
/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
/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
/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
/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
/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
/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
/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
/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
/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
/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
/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
/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
/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
/api/v1/routines/{id}
Delete a routine
Parameters
id
required
|
path · integer |
Responses
- 204 Deleted
-
404
No such routine for this customer
Error
/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
/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
/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
/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
/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 |
Responses
-
200
A page of runs
RunList -
400
Malformed cursor
Error
/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: |
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
/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 |
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