API reference

The veldhost API lets you script everything the portal buttons do: deploys, logs, health, staging, cron, backups, domains and DNS. It powers the mpc CLI and works from any HTTP client, CI pipeline or AI agent.

Authentication

Create a token under API tokens and send it as a bearer token:

curl -H "Authorization: Bearer mpc_..." https://manage.veldhost.eu/api/v1/sites

When you create a token you choose which sites it can touch and what it may do: read (always on), deploy, manage (site settings, domains, staging, cron, backups & restore, webhooks), dns, and two you turn on on purpose: data (SQL on your databases) and billing (propose orders for you to approve). Scopes and permissions explains each and who can grant it. Rate limit: 120 requests/minute. A machine-readable spec lives at /api/v1/openapi.json.

Sites

GET    /api/v1/sites                      your sites: state, url, health, deploy_state
POST   /api/v1/sites/{slug}/deploy        deploy from git ({"ref": "main"} optional)
GET    /api/v1/sites/{slug}/deploys       deploy history
GET    /api/v1/sites/{slug}/logs?log=app  last 200 lines (app, php, nginx-error, access)
GET    /api/v1/sites/{slug}/health        live health + open incidents
GET    /api/v1/sites/{slug}/traffic       disk + monthly traffic

Backups & staging

GET    /api/v1/sites/{slug}/backups       list restore points
POST   /api/v1/sites/{slug}/backups       back up now
POST   /api/v1/sites/{slug}/restore       {"backup_id": 12} — replaces live content
GET    /api/v1/sites/{slug}/staging       staging status + url
POST   /api/v1/sites/{slug}/staging       {"action": "create" | "refresh" | "off"}

Staging is owner-only. On a plan that doesn't include it, creating it adds a paid add-on, so the API answers 402 with requires_order: propose it as an order (below) instead.

Site settings & domains

GET    /api/v1/sites/{slug}/settings      PHP/Node.js version, start command, build steps, domain, env var names
PUT    /api/v1/sites/{slug}/env/{KEY}     {"value": "...", "secret": true} create or replace (manage)
DELETE /api/v1/sites/{slug}/env/{KEY}     remove a variable (manage)
PATCH  /api/v1/sites/{slug}/runtime       {"php_version": "8.4"} or node_version / start_command (manage)
PATCH  /api/v1/sites/{slug}/build         install/build/test commands, migrations, staging branch (deploy)
POST   /api/v1/sites/{slug}/domain        {"domain": "example.com"} connect a domain (manage)

Connecting a domain answers connected with what to change at the registrar or DNS host, or needs_portal with a continue_url when the domain has email that must be copied across first.

Releases

POST   /api/v1/sites/{slug}/deploys/{id}/approve    publish a release awaiting approval (deploy)
POST   /api/v1/sites/{slug}/deploys/{id}/discard    throw it away; the live site is untouched
POST   /api/v1/sites/{slug}/deploys/{id}/rollback   files back to an earlier release; the database is not changed

GET /deploys marks each one with awaiting_approval and can_roll_back_to.

Orders

POST   /api/v1/orders                     {"kind": "plan_change", "plan": "pro"} propose (billing)
GET    /api/v1/orders                     proposed orders and their state
GET    /api/v1/orders/{id}                one order: price, approve_url, outcome
DELETE /api/v1/orders/{id}                withdraw it while it waits

Kinds: new_site, email, plan_change, staging, warehouse. Nothing is bought by the API: the account's owners and billing members are emailed and approve it in the portal. See Ordering with an AI assistant.

Scheduled tasks

GET    /api/v1/sites/{slug}/cron          list jobs with their index
POST   /api/v1/sites/{slug}/cron          {"schedule": "*/5 * * * *", "command": "php artisan queue:work"}
DELETE /api/v1/sites/{slug}/cron/{index}  remove a job

Domains & DNS

GET    /api/v1/domains                    registered domains: expiry, auto-renew, DNSSEC
GET    /api/v1/domains/{domain}/registrar registry vs live nameservers, DNSSEC state, lock, privacy
PUT    /api/v1/domains/{domain}/nameservers  {"mode": "custom", "nameservers": ["ns1.example.com", "ns2.example.com"]}
GET    /api/v1/dns                        your DNS zones
POST   /api/v1/dns/zones/probe            {"domain": "example.com"} dry run: live zone as an import plan + registrar steps (dns scope)
GET    /api/v1/dns/{zone}/records         zone records
POST   /api/v1/dns/{zone}/records         {"name": "www", "type": "CNAME", "ttl": 3600, "content": "example.com."}
PUT    /api/v1/dns/{zone}/records/{id}    update a record
DELETE /api/v1/dns/{zone}/records/{id}    delete a record

DNS changes are live on our nameservers in under a second.

Changing nameservers needs the dns scope, and is refused with a 409 and a continue_url while DNSSEC is active on the domain: the registry vouches for our signing key, so moving the delegation before that is withdrawn (and its cached lifetime waited out) would make validating resolvers refuse the domain outright. The portal walks you through it. Your zone and records here are kept either way, so switching back is immediate.

Email

GET    /api/v1/mail                       domains with veldhost email, and every mailbox with its status
GET    /api/v1/mail/{domain}              one email-only domain: its verification record, or the mail records still to add
POST   /api/v1/mail/{domain}/verify       check the verification record now (dns scope)

Mailboxes themselves are created in the portal: that takes the mailbox's password, which has no business passing through an API token. A mailbox's status is active, locked (the account's payment is overdue: it still receives mail, nobody can sign in) or archived (removed; its mail is kept until deleted_for_good_at).

Pagination

Every list endpoint (/sites, /deploys, /backups, /cron, /domains, /dns, /dns/{zone}/records, /apps) takes ?limit= (default 20, max 100) and ?cursor=, and answers with a meta object next to its array:

GET /api/v1/sites?limit=50

{"sites": [ ... ], "meta": {"next_cursor": "eyJ2IjoxLCJvIjo1MH0", "has_more": true}}

GET /api/v1/sites?limit=50&cursor=eyJ2IjoxLCJvIjo1MH0

Pass next_cursor back as cursor until has_more is false. Cursors are opaque and expire with nothing — a cursor from an hour ago still works. Deploy and backup histories are keyed on the row, so new entries arriving at the head don't shift the page you're on. A bad limit or cursor is a 422.

Errors

Every 4xx and 5xx has the same body, whatever produced it:

{"error": {"type": "validation_error",
           "message": "The schedule field is required.",
           "details": {"schedule": ["The schedule field is required."]},
           "request_id": "req_01j9x3k8v2q0f4s7bq5e9a2m3n"}}

type is stable and safe to branch on: unauthenticated (401), forbidden (403 — missing scope, site not pinned to the token, or owner-only), not_found (404), conflict (409 — site not active, or a step that needs a human in the portal; those add continue_url), validation_error (422, with details per field), rate_limited (429), idempotency_conflict (409), upstream_error (502 — a registry didn't answer; nothing was changed) and server_error (500). message is for people. request_id is the same value as the X-Request-Id header that every response carries — quote it when you write to support and we can find the exact request in our logs.

Idempotency

Networks time out. To retry an action without queuing it twice, send an Idempotency-Key header (any string of 1–200 printable characters; a UUID is ideal) on POST /deploy, /backups, /restore, /staging, /cron, DELETE /cron/{index}, the DNS record writes and PUT /nameservers:

curl -X POST -H "Authorization: Bearer mpc_..." \
     -H "Idempotency-Key: 6c1f0d2e-9b7a-4f2e-8e1a-3d5c7b9a1f00" \
     https://manage.veldhost.eu/api/v1/sites/my-site/deploy

For 24 hours, the same key with the same request returns the stored first response — same status, same body — with Idempotent-Replayed: true, and nothing runs again. The same key with a different request (another path or body) is a 409 idempotency_conflict: a key names one action. Keys are scoped to the token that sent them, and only successful responses are remembered, so a request that failed can be retried with the same key once you've fixed the cause. Without the header the endpoints behave as before.

Rate limits

120 requests a minute per token owner — every token of the same user shares one bucket, which is what makes a runaway script easy to spot and stop. Each response carries X-RateLimit-Limit and X-RateLimit-Remaining; going over returns 429 rate_limited with Retry-After in seconds. Poll /health and /deploys at a sane interval rather than in a tight loop, and page with limit=100 when you need everything.

Versioning

This is v1, at /api/v1, currently spec version 1.4. Additive changes — new endpoints, optional parameters, extra response fields such as meta — may ship at any time; ignore fields you don't know. Breaking changes only ever ship under a new prefix (/api/v2), and v1 keeps working for at least 12 months after that with a Deprecation header on its responses and a sunset date in the changelog.

What the API deliberately won't do

It never takes payment and never needs card details: orders are proposals that a person approves in the portal, with the same terms and checkout as ordering by hand. Deleting a site, a domain or a DNS zone, creating tokens and changing the team are not exposed at all. When an AI assistant (an OAuth client or the MCP server) calls something that cannot be undone, such as restoring a backup, enabling DNSSEC or dropping a warehouse database, the first call answers 409 with needs_confirmation and a confirm_token, and only a repeat with the token acts; a personal token's script is not asked.