{ "openapi": "3.1.0", "info": { "title": "Manage – Protocol v1", "version": "1.0.0", "summary": "Update and backup interface between a project instance and the Manage server.", "description": "The four endpoints a client needs: fetch the release manifest, download the release package, upload a backup, report status.\n\nEvery request authenticates with two headers (`X-Manage-Instance`, `X-Manage-Token`). There is no session, no cookie and no shared password. The server stores only the SHA-256 hash of the token.\n\nEvery successfully authenticated request additionally updates `last_seen_at` and the instance's latest IP.\n\nThe reference implementation of the client is fully documented in the handbook; see `lib/remote.php`, `lib/updater.php` and `lib/backup.php`.\n\nNote: the server's actual `error` strings in the response examples below are still German text — this document's descriptions are in English, the API's literal responses are not.", "license": { "name": "See the handbook" } }, "servers": [ { "url": "/api/v1", "description": "This Manage installation" } ], "tags": [ { "name": "Update", "description": "Determine the release and fetch the package." }, { "name": "Backup", "description": "Transfer backup archives to the server." }, { "name": "Status", "description": "Report the instance's state." } ], "security": [ { "instanceId": [], "instanceToken": [] } ], "paths": { "/manifest.php": { "get": { "tags": ["Update"], "operationId": "getManifest", "summary": "Fetch the release the instance should install", "description": "Returns version, download address, size and SHA-256 of the current release.\n\n`package_url` is built from the server configuration (`MANAGE_PUBLIC_URL`), not from the request's `Host` header. A spoofed header therefore can't redirect a client to a foreign server.\n\nRelease metadata is not public: the endpoint requires a valid token.", "responses": { "200": { "description": "Current release", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Manifest" }, "example": { "success": true, "latest": "v1.3.0", "version": "v1.3.0", "package_url": "https://manage.example.org/api/v1/package.php?version=v1.3.0", "sha256": "70f17aae44a9afdd948de1767daa61f936bbecb52096753757791e230a22f024", "size": 2199, "published_at": "2026-08-20T09:20:43+00:00" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Disabled" }, "404": { "description": "No valid release is published.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "success": false, "error": "Es ist kein gültiges Release veröffentlicht." } } } }, "405": { "$ref": "#/components/responses/MethodNotAllowed" }, "429": { "$ref": "#/components/responses/RateLimited" }, "500": { "$ref": "#/components/responses/ServerError" } } } }, "/package.php": { "get": { "tags": ["Update"], "operationId": "getPackage", "summary": "Download the release ZIP", "description": "Streams the release archive. The response is `application/zip` with `Content-Length` and `Cache-Control: private, no-store`; no JSON on success.\n\nThe client **must** check size and SHA-256 against the manifest and delete the file on mismatch. Without this check, arbitrary code gets deployed.", "parameters": [ { "name": "version", "in": "query", "required": true, "description": "Version in `vMAJOR.MINOR.PATCH` format.", "schema": { "$ref": "#/components/schemas/Version" }, "example": "v1.3.0" } ], "responses": { "200": { "description": "The release archive", "headers": { "Content-Disposition": { "description": "attachment; filename=\"…zip\"", "schema": { "type": "string" } }, "Content-Length": { "description": "Size in bytes, identical to `size` from the manifest.", "schema": { "type": "integer" } }, "Cache-Control": { "description": "Always `private, no-store`.", "schema": { "type": "string" } } }, "content": { "application/zip": { "schema": { "type": "string", "format": "binary" } } } }, "400": { "description": "Invalid version format", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "success": false, "error": "Ungültige Version." } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Disabled" }, "404": { "description": "Release does not exist", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "405": { "$ref": "#/components/responses/MethodNotAllowed" }, "429": { "$ref": "#/components/responses/RateLimited" }, "500": { "$ref": "#/components/responses/ServerError" } } } }, "/backup.php": { "post": { "tags": ["Backup"], "operationId": "uploadBackup", "summary": "Upload a backup archive", "description": "Accepts a ZIP and stores it under the instance.\n\nChecked in this order: upload error code, `is_uploaded_file`, size limit (`MANAGE_BACKUP_MAX_UPLOAD_BYTES`), ZIP signature, filename pattern, checksum after storing. If the checksum doesn't match, the file is deleted again and `400` is reported.\n\nAn existing filename is never overwritten: the server appends `-2`, `-3` and reports the name actually used.\n\nErrors during S3 archiving do **not** fail the upload – the local copy is stored and gets caught up later.", "requestBody": { "required": true, "content": { "multipart/form-data": { "schema": { "type": "object", "required": ["backup"], "properties": { "backup": { "type": "string", "format": "binary", "description": "The ZIP archive." }, "filename": { "type": "string", "pattern": "^backup-\\d{8}-\\d{6}(?:-\\d+)?\\.zip$", "description": "Desired name. The server assigns `backup-YYYYmmdd-HHMMSS.zip` if omitted.", "example": "backup-20260820-092104.zip" }, "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$", "description": "Checksum of the archive. Recomputed and compared server-side." }, "meta": { "type": "string", "description": "JSON object with `trigger`, `file_count`, `source_bytes`, `app_version`.", "example": "{\"trigger\":\"cron\",\"file_count\":3,\"source_bytes\":63,\"app_version\":\"v1.3.0\"}" } } }, "encoding": { "backup": { "contentType": "application/zip" } } } } }, "responses": { "200": { "description": "Backup stored", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BackupResult" }, "example": { "success": true, "instance": "myproject-prod", "filename": "backup-20260820-092104.zip", "size": 427, "sha256": "824f3f8000000000000000000000000000000000000000000000000000000000", "retention": 30, "s3": { "enabled": false, "uploaded": false, "pending": 0 } } } } }, "400": { "description": "Upload rejected: file missing, limit exceeded, not a ZIP, wrong name or checksum.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "success": false, "error": "Die hochgeladene Datei muss ein ZIP-Archiv sein." } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Disabled" }, "405": { "$ref": "#/components/responses/MethodNotAllowed" }, "429": { "$ref": "#/components/responses/RateLimited" } } } }, "/heartbeat.php": { "post": { "tags": ["Status"], "operationId": "sendHeartbeat", "summary": "Report status and receive update information", "description": "Reports the instance's state. All fields are optional; missing fields leave the server's current value unchanged.\n\nThe response includes the update information, so it replaces a separate call to `manifest.php` for simple monitoring.", "requestBody": { "required": false, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/HeartbeatRequest" }, "example": { "version": "v1.3.0", "php_version": "8.3.6", "disk_free": 12884901888, "pending_migrations": 0, "last_backup_at": "2026-08-20T09:21:04+00:00" } } } }, "responses": { "200": { "description": "Status accepted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/HeartbeatResponse" }, "example": { "success": true, "instance": "myproject-prod", "latest": "v1.3.0", "update_available": false, "server_time": "2026-08-20T09:23:11+00:00" } } } }, "400": { "description": "Request body is not valid JSON.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Disabled" }, "405": { "$ref": "#/components/responses/MethodNotAllowed" }, "429": { "$ref": "#/components/responses/RateLimited" } } } } }, "components": { "securitySchemes": { "instanceId": { "type": "apiKey", "in": "header", "name": "X-Manage-Instance", "description": "The instance id, for example `myproject-prod`." }, "instanceToken": { "type": "apiKey", "in": "header", "name": "X-Manage-Token", "description": "The token shown once when the instance was created." } }, "responses": { "Unauthorized": { "description": "Headers missing, instance unknown, or token wrong – deliberately indistinguishable, so instance ids can't be brute-forced.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "success": false, "error": "Authentifizierung fehlgeschlagen." } } } }, "Disabled": { "description": "Instance exists but is deactivated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "success": false, "error": "Diese Instanz ist deaktiviert." } } } }, "MethodNotAllowed": { "description": "Wrong HTTP method. The response carries an `Allow` header.", "headers": { "Allow": { "schema": { "type": "string" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "RateLimited": { "description": "Too many failed authentications from this IP (`MANAGE_API_RATE_LIMIT_MAX` per `MANAGE_API_RATE_LIMIT_WINDOW` seconds, 240 per 300s out of the box). A successful login resets the counter.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "success": false, "error": "Zu viele Anfragen. Bitte später erneut versuchen." } } } }, "ServerError": { "description": "Unexpected server error; details are in the server log.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } }, "schemas": { "Version": { "type": "string", "pattern": "^v\\d+\\.\\d+\\.\\d+$", "examples": ["v1.3.0"] }, "Error": { "type": "object", "description": "Shape of **every** error response.", "required": ["success", "error"], "properties": { "success": { "type": "boolean", "const": false }, "error": { "type": "string", "description": "Plain-text message, for logging and display." } } }, "Manifest": { "type": "object", "required": ["success", "latest", "version", "package_url", "sha256", "size", "published_at"], "properties": { "success": { "type": "boolean", "const": true }, "latest": { "$ref": "#/components/schemas/Version" }, "version": { "allOf": [{ "$ref": "#/components/schemas/Version" }], "description": "Equivalent to `latest`; both fields exist for compatibility reasons." }, "package_url": { "type": "string", "format": "uri", "description": "Absolute download address, built from `MANAGE_PUBLIC_URL`." }, "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$", "description": "Checksum of the package. Must be verified by the client." }, "size": { "type": "integer", "description": "Package size in bytes." }, "published_at": { "type": "string", "format": "date-time" } } }, "BackupResult": { "type": "object", "required": ["success", "instance", "filename", "size", "sha256", "retention", "s3"], "properties": { "success": { "type": "boolean", "const": true }, "instance": { "type": "string" }, "filename": { "type": "string", "description": "The name actually assigned – may differ from the one requested if it was already taken." }, "size": { "type": "integer" }, "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, "retention": { "type": "integer", "description": "How many days the server keeps this archive." }, "s3": { "type": "object", "description": "State of the optional S3 archive. Never a reason for failure.", "properties": { "enabled": { "type": "boolean" }, "uploaded": { "type": "boolean" }, "pending": { "type": "integer" } } } } }, "HeartbeatRequest": { "type": "object", "properties": { "version": { "type": "string", "description": "Installed version of the application. Empty if it can't be determined." }, "php_version": { "type": "string", "examples": ["8.3.6"] }, "disk_free": { "type": "integer", "description": "Free disk space in bytes." }, "pending_migrations": { "type": "integer", "minimum": 0 }, "last_backup_at": { "type": "string", "format": "date-time" } } }, "HeartbeatResponse": { "type": "object", "required": ["success", "instance", "latest", "update_available", "server_time"], "properties": { "success": { "type": "boolean", "const": true }, "instance": { "type": "string" }, "latest": { "type": "string", "description": "Current release on the server, or empty if none is published." }, "update_available": { "type": "boolean", "description": "Result of a `version_compare` between `latest` and the reported version. `false` as long as the instance reports no version." }, "server_time": { "type": "string", "format": "date-time" } } } } } }