REST API
The Manager's REST routes cover what GraphQL does not: a backend registering itself and receiving triggers, the ESB, and the internal routes the proxy, the CLI and the browser call. Below is every HTTP route the Manager serves, which credential each one accepts, and the request and response shapes of the ones you call from your own code (/api/machine/*).
Base URL
https://manager.example.comEvery path on this page is absolute and already includes its own prefix: /api/machine/check, /esb/..., /query, /pilet-feed/.... Do not append /api to the base URL; the Manager serves several route groups that are not under /api at all.
Authentication
The Manager has two distinct authentication schemes, and which one applies depends on the route group, not on the verb or the content type.
Machine-user credentials
/api/machine/* and /esb/* are authenticated by a machine-user Bearer token and nothing else. They are not proxy-secret gated, so a backend can call them directly at the Manager's address.
Authorization: Bearer <token>Basic authentication is rejected on these routes before the credential is looked up (401 {"error":"Invalid authorization format. Expected: Bearer <token>"}). Basic-auth machine users are validated by the proxy on application routes, never by the Manager's own machine routes.
Repeated failed authentications from one client trip a throttle that answers 429 Too Many Requests with a Retry-After header.
The proxy trust boundary
Every other route is gated on the shared secret the trusted proxy sends as X-Proxy-Auth, checked before any credential is read. That ordering is deliberate: without it, anyone who can reach the Manager's port could drive the PBKDF2 verification path on /query and /pilets/* and use it as an unauthenticated token-validity oracle.
Practically, that means:
- These routes are not callable directly. They are reached through the proxy, which adds
X-Proxy-Authand (for user routes) theX-Token-*identity headers it derived from the OIDC session. - If
PFY_PROXY_SECRETis unset on the Manager, the gate fails closed and every one of these routes answers401; see Configuration. The single exception is the development escape hatch (PFY_ENV=developmentandPFY_DEV_USER_OVERRIDE_ENABLED). - The
401carries a JSON body whoseerrorfield says which of the two it was: no secret configured on the Manager, or a missing/wrongX-Proxy-Auth. See Error Response Format.
Route map
The complete set of HTTP routes the Manager serves, and how each is authenticated. Only a few are integration surfaces; the rest are listed so that an operator reading a proxy log or an access-control review can account for every path.
| Route | Auth | Purpose |
|---|---|---|
GET /healthz | none (separate health port) | Liveness. Always 200 {"status":"healthy"}. |
GET /readyz | none (separate health port) | Readiness. 200 {"status":"ready"}, or 503 when the database is unreachable. |
GET/HEAD /health | none (API port) | Readiness on the API port, the route the proxy probes. Same verdict as /readyz. Anonymous callers get the status code and ok or unavailable; a caller presenting X-Proxy-Auth gets the full /readyz body. A wrong secret answers exactly as no secret does. |
POST /api/machine/register-backend | machine user | Register a backend for trigger callbacks. |
GET /api/machine/check | machine user | Verify a machine-user credential. |
PUT /api/machine/backend/:id/heartbeat | machine user | Liveness beat for a registration. |
DELETE /api/machine/backend/:id | machine user | Graceful deregistration. |
GET /api/machine/application/:id/tenants | machine user | Tenants that have the application enabled. |
POST /api/machine/comms/emit | machine user | Emit a durable notification. |
POST /api/machine/comms/publish | machine user | Publish a lossy live-data hint. |
ANY /esb/:applicationId/*endpointPath | machine user | Enterprise Service Bus forwarding. |
ANY /query | proxy secret → PAT → user identity | The GraphQL API. |
GET /gql | development only | GraphQL playground. Registered only when PFY_ENV=development. |
GET/POST/DELETE /pilets/:projectId/... | proxy secret → PAT → user identity | Pilet package list, download, upload, delete. |
GET /api/audit/export | proxy secret → PAT → system administrator | SIEM audit export. |
GET /api/audit/evidence | proxy secret → PAT → system administrator | Lists the registered compliance-evidence query names. |
GET /api/audit/evidence/:name | proxy secret → PAT → system administrator | Runs one named evidence query over an optional RFC3339 from/to window and returns its self-attesting result (rows plus audit-chain status). 404 for an unknown name. |
GET /api/access/review | proxy secret → PAT → system administrator | Access-review report. |
POST /api/access/review/attest | proxy secret → PAT → system administrator | Record an access-review attestation. |
GET/POST /cli/authorize | proxy secret → user identity | The browser consent screen for the CLI device-authorization grant. GET renders the approve/deny page, POST records the decision and mints a PAT sealed to the CLI's public key. Only an authenticated browser user can approve; there is no machine path to it. |
POST /api/cli/login/start | proxy secret only | CLI begins a device-authorization login and registers its public key. No user identity is read; the CLI has no token yet. |
POST /api/cli/login/poll | proxy secret only | CLI polls for the sealed PAT. Delivers it exactly once; rate-limited per IP and per session with 429 + Retry-After. |
POST /api/validate-proxy-auth | proxy secret only | Credential validation for the proxy (see below). |
POST /api/validate-oauth-user | proxy secret only | OAuth user validation for the proxy. |
GET /api/frontend-config/:application_id/:tenant_id | proxy secret only | Authoritative tenant-aware frontend config cascade. |
GET /api/frontend-config/:application_id | proxy secret only | Deprecated tenant-less form; project + app defaults only. |
GET /api/proxy/user/:user_id/tenants | proxy secret only | Tenant catalog for the proxy's tenant selector. |
GET /api/proxy/comms/:appID/notifications | proxy secret + injected identity | The recipient inbox the bell/inbox pilet polls, newest-first and cursor-paginated. |
GET /api/proxy/comms/:appID/unread | proxy secret + injected identity | Unread probe. An EXISTS, not a count; the bell renders a dot. |
POST /api/proxy/comms/:appID/notifications/read | proxy secret + injected identity | Mark read, by ids or all. |
POST /api/proxy/comms/:appID/notifications/seen | proxy secret + injected identity | Mark seen, by ids only. all is refused. |
GET /api/proxy/comms/:appID/ws | proxy secret + injected identity | The comms websocket upgrade. |
POST /api/proxy/wake | proxy secret only | Scale-from-zero: raise a deployment's replica floor to at least 1. 202 on success, 404 unknown app/env, 409 not scale-to-zero eligible, 502 orchestrator failure. Idempotent. Eligibility is the deployment's scale_to_zero flag, which no API can set, so this route answers 409 for every existing deployment; see Known Issues. |
GET /api/proxy/ready | proxy secret only | Scale-from-zero readiness for app_id + env. {"ready":bool,"backends":n}; ready:false is distinct from 404 (unknown app/env). |
GET /api/proxy/deployment | proxy secret only | Scale-from-zero eligibility for app_id + env: {"scale_to_zero":bool,"healthy_backends":n}. The proxy engages a cold start only on eligible + 0 healthy, so it never infers one from a dial error. |
GET /language-packs/:applicationId/:locale | proxy secret only | Language pack download. |
GET /pilet-feed/:applicationId | proxy secret only | Shared-vhost pilet feed. |
GET /pilet-feed/:applicationId/:tenantId | proxy secret only | Dedicated-vhost feed, honouring that tenant's pilets.pins. |
GET /pilet-bundle/:applicationId/:projectId/:name/:version | proxy secret only | Pilet bundle bytes for the browser. The rendered application vhost forwards /pilets/* here; the application id is written by the renderer, and the handler refuses a project that is not that application's own. See Pilet API. |
GET /pilet-keys/:applicationId | proxy secret only | JWKS-like map of pilet signing key id to base64 Ed25519 public key. A local development and debugging convenience, not the frontend trust anchor: the loader verifies against the public key delivered in the trusted window.__PRODUCTIFY__ injection, never a key fetched over this route. |
The /api/proxy/comms/* identity comes from headers, not the body
Those handlers take the caller's identity strictly from the X-User-Id and X-Tenant-Id headers the proxy injects, and the :appID segment is written by the rendered vhost (one vhost per application), never from the request body or query string. Reads and writes are anchored to the caller's own recipient rows in the active tenant and application, which is why marking someone else's id read simply matches nothing rather than returning an error.
Endpoints
Validate Proxy Authentication
Validates credentials forwarded by the Proxy (Personal Access Tokens, machine user Bearer tokens, and machine user Basic auth). Used internally by the Proxy; it is not intended for application code.
Endpoint:
POST /api/validate-proxy-authAuthentication: Proxy shared secret (requests not originating from the proxy are rejected)
Request Body:
{
"authorization_header": "Bearer <token>",
"application_id": "application-uuid"
}Response (Success):
{
"valid": true,
"user": {
"id": "user-or-machine-user-uuid",
"email": "user@example.com",
"name": "User Name",
"username": "username",
"is_machine_user": false,
"is_personal_token": true
},
"tenant": {
"id": "tenant-uuid",
"name": "Tenant Name"
},
"application": {
"has_access": true,
"role": "admin"
}
}Response (Invalid):
{
"valid": false,
"message": "Invalid or expired authentication credentials"
}Check Machine User Auth
Verifies the current machine user authentication status.
Endpoint:
GET /api/machine/checkAuthentication: Bearer Token (Machine User) - Required
Response: always 200. authenticated is false (with a message) when the credential did not resolve to an enabled machine user with an environment.
{
"authenticated": true,
"machine_user_id": "machine-user-uuid",
"environment_id": "environment-uuid"
}Example:
curl -X GET https://manager.example.com/api/machine/check \
-H "Authorization: Bearer <machine-user-token>"Register Backend
Registers a backend service to receive trigger execution callbacks.
Endpoint:
POST /api/machine/register-backendAuthentication: Bearer Token (Machine User) - Required
Request Body:
{
"application_id": "application-uuid",
"callback_url": "https://payment.example.com/triggers/callback",
"version": "1.0.0"
}version is optional. callback_url must be an absolute URL and must not point at a loopback, private-network or cloud-metadata address (400 Invalid callback_url otherwise). Re-registering an existing backend (same application, same machine user) answers 200 instead of 201, updates its callback URL, version and heartbeat timestamp, and rotates the token: the response carries a new token and the old one stops working.
Response:
{
"success": true,
"registered_id": "backend-uuid",
"token": "callback-auth-token",
"message": "Backend registered successfully",
"environment_id": "environment-uuid",
"application_id": "application-uuid"
}Registration binds to (application, environment): the machine user is environment-scoped, and the Manager returns the resolved environment_id. The application must belong to that environment's project (404 Application not found or does not belong to your project otherwise).
Example:
curl -X POST https://manager.example.com/api/machine/register-backend \
-H "Authorization: Bearer <machine-user-token>" \
-H "Content-Type: application/json" \
-d '{
"application_id": "application-uuid",
"callback_url": "https://payment.example.com/triggers/callback"
}'Backend Heartbeat
Endpoint: PUT /api/machine/backend/:id/heartbeat
Lightweight liveness signal for a registered backend (default cadence 30 s in both SDKs); it only refreshes the registration's last_heartbeat. Both SDKs PUT this route on each beat; they do not re-POST registration to stay alive. A 404 means the registration is gone (Manager restarted or reaped); the SDK re-registers only on 404 and resumes with the new id.
Authenticated as the machine user. Responses:
200with{"success": true, "registered_id": "backend-uuid", "message": "Heartbeat recorded"}404with{"success": false, "message": "Backend registration not found"}when the id is unknown or not a UUID403with{"success": false, "message": "Backend registration does not belong to your machine user"}
Unregister Backend
Endpoint: DELETE /api/machine/backend/:id
Graceful deregistration on shutdown so the backend no longer receives trigger callbacks, authenticated as the machine user. Responses: 200 {"success": true, "message": "Backend unregistered"}, 404 when the registration is already gone, 403 when it belongs to another machine user. Both SDKs treat the 404 as success, so unregister() does not throw on a missing registration.
List Application Tenants
Endpoint: GET /api/machine/application/:id/tenants
Lists the tenants that have the application enabled, authenticated as the machine user. A trigger fires once per deployment with a tenant-agnostic payload; backends use this route to enumerate enabled tenants and fan the work out per tenant. The application must belong to the machine user's environment's project; otherwise the response is 404 with success: false.
Response:
{
"success": true,
"tenants": [{ "id": "tenant-uuid", "name": "Acme" }]
}Comms
Endpoints:
POST /api/machine/comms/emitemits a durable, per-user notification. Answers202with{"notification_id", "request_id", "recipient_count"}.POST /api/machine/comms/publishpublishes a lossy, at-most-once, tenant-wide live-data hint. Answers202with{"published": true}.
Both authenticate as the machine user and carry application_id and tenant_id in the body (the machine user is environment-scoped, so the tenant travels as an authorized request). Both answer 403 {"error":"comms_disabled"} when communications are off for that tenant and application, and 429 {"error":"rate_limited"} under load. See Add Notifications and Live Data Updates for the full request shapes.
Error Responses
All REST endpoints return standard HTTP status codes:
Success Codes
200 OK- Request successful201 Created- Resource created successfully
Client Error Codes
400 Bad Request- Invalid request format or parameters401 Unauthorized- Missing or invalid authentication403 Forbidden- Insufficient permissions404 Not Found- Resource not found429 Too Many Requests- Machine-auth throttle (/api/machine/*,/esb/*), CLI login polling, or comms rate limit; honourRetry-Afterwhere present
Server Error Codes
500 Internal Server Error- Server-side error occurred
Error Response Format
There are three shapes, and which one you get depends on the route group.
Machine-user routes (/api/machine/*, /esb/*) and any error raised by a handler after authentication has already succeeded return structured JSON:
{
"error": "Error message"
}The registration endpoint reports errors in its response envelope instead:
{
"success": false,
"message": "Machine user not authenticated"
}The proxy trust boundary answers with a 401 and a diagnostic JSON body. Every proxy-secret-gated route (/query, /pilets/*, /api/audit/*, /api/access/*, /cli/authorize, and all of /api/proxy/*, /api/frontend-config/*, /api/validate-*, /api/cli/login/*, /language-packs/*, /pilet-feed/*, /pilet-bundle/*, /pilet-keys/*) answers 401 with the same {"error": "..."} shape as the machine routes. The text tells you which case you are in:
manager misconfiguration: no proxy secret is configured (PFY_PROXY_SECRET is unset and dev_user_override is not enabled) ...: the Manager has noPFY_PROXY_SECRET, so the boundary fails closed for everyone. The credential you sent was never examined.missing or invalid X-Proxy-Auth header: this endpoint is reachable only through the trusted proxy ...: the request did not come through the proxy, or the proxy's secret does not match the Manager's.no authenticated user: the request reached the manager without the X-Token-Subject / X-Token-User-Email / X-Token-User-Name headers ...: the proxy secret was fine but no identity was forwarded (an unauthenticated browser or CLI on a user route).invalid authenticated user: the X-Token-Subject header ... is not a valid UUID.
The rejection is not written to the Manager log (a rejected request is not an error), so the response body is the only place the reason appears. If you get one from a Manager that reports healthy on /healthz, read the error text first, then check the proxy secret on both sides.
The CLI consent screen renders HTML instead. /cli/authorize is a browser surface, so its failures (404 unknown session, 410 expired or already handled, 401 not signed in) render a terminal HTML page rather than JSON.
Usage Patterns
Machine User Registration Flow
#!/bin/bash
# 1. Create machine user (via GraphQL, through the proxy host, with your PAT)
RESPONSE=$(curl -X POST https://manager.example.com/query \
-H "Authorization: Bearer $USER_PAT" \
-H "Content-Type: application/json" \
-d '{
"query": "mutation { createMachineUserWithCredentials(environmentId: \"'$ENVIRONMENT_ID'\", input: { name: \"Backend Service\", username: \"Bearer\", hashedKey: \"__GENERATE_TOKEN__\", enabled: true }) { generatedToken machineUser { id } } }"
}')
MACHINE_TOKEN=$(echo $RESPONSE | jq -r '.data.createMachineUserWithCredentials.generatedToken')
# 2. Verify authentication
curl -X GET https://manager.example.com/api/machine/check \
-H "Authorization: Bearer $MACHINE_TOKEN"
# 3. Register backend for triggers
curl -X POST https://manager.example.com/api/machine/register-backend \
-H "Authorization: Bearer $MACHINE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"application_id": "'$APPLICATION_ID'",
"callback_url": "https://backend.example.com/triggers/callback"
}'Health Check Integration
Use the check endpoint for health monitoring:
// Health check endpoint
app.get("/health", async (req, res) => {
try {
const response = await fetch(
"https://manager.example.com/api/machine/check",
{
headers: {
Authorization: `Bearer ${process.env.MACHINE_TOKEN}`,
},
}
);
if (response.ok) {
const data = await response.json();
res.json({
status: "healthy",
authenticated: data.authenticated,
});
} else {
res.status(503).json({ status: "unhealthy" });
}
} catch (error) {
res.status(503).json({
status: "unhealthy",
error: error.message,
});
}
});Best Practices
Error Handling
Always handle errors gracefully:
async function checkMachineUserAuth(token) {
try {
const response = await fetch(
"https://manager.example.com/api/machine/check",
{
headers: { Authorization: `Bearer ${token}` },
}
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return data.authenticated;
} catch (error) {
console.error("Authentication check failed:", error);
return false;
}
}Retry Logic
Implement retries for transient failures:
import requests
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry
def create_session_with_retries():
session = requests.Session()
retry = Retry(
total=3,
backoff_factor=1,
status_forcelist=[500, 502, 503, 504]
)
adapter = HTTPAdapter(max_retries=retry)
session.mount('http://', adapter)
session.mount('https://', adapter)
return session
# Use the session
session = create_session_with_retries()
response = session.get(
'https://manager.example.com/api/machine/check',
headers={'Authorization': f'Bearer {token}'}
)Timeout Configuration
Set appropriate timeouts:
const response = await fetch("https://manager.example.com/api/machine/check", {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(5000), // 5 second timeout
});REST or GraphQL
Use REST for what only REST serves: backend registration and heartbeats for triggers, machine-user validation, the ESB, and the routes the proxy calls. Use GraphQL for everything else: managing projects, tenants, applications, settings, users and tokens, and querying related data in one request.
API Client Examples
Node.js
class ManagerRestClient {
constructor(baseUrl, token) {
this.baseUrl = baseUrl;
this.token = token;
}
async checkAuth() {
const response = await fetch(`${this.baseUrl}/api/machine/check`, {
headers: { Authorization: `Bearer ${this.token}` },
});
return response.json();
}
async registerBackend(applicationId, callbackUrl, version) {
const response = await fetch(
`${this.baseUrl}/api/machine/register-backend`,
{
method: "POST",
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
application_id: applicationId,
callback_url: callbackUrl,
version,
}),
}
);
return response.json();
}
}
// Usage
const client = new ManagerRestClient("https://manager.example.com", token);
await client.checkAuth();Python
import requests
class ManagerRestClient:
def __init__(self, base_url, token):
self.base_url = base_url
self.token = token
self.headers = {'Authorization': f'Bearer {token}'}
def check_auth(self):
response = requests.get(
f'{self.base_url}/api/machine/check',
headers=self.headers
)
return response.json()
def register_backend(self, application_id, callback_url, version=''):
response = requests.post(
f'{self.base_url}/api/machine/register-backend',
headers={**self.headers, 'Content-Type': 'application/json'},
json={
'application_id': application_id,
'callback_url': callback_url,
'version': version
}
)
return response.json()
# Usage
client = ManagerRestClient('https://manager.example.com', token)
client.check_auth()Go
package main
import (
"bytes"
"encoding/json"
"net/http"
)
type ManagerRestClient struct {
BaseURL string
Token string
Client *http.Client
}
func (c *ManagerRestClient) CheckAuth() (map[string]interface{}, error) {
req, _ := http.NewRequest("GET", c.BaseURL+"/api/machine/check", nil)
req.Header.Set("Authorization", "Bearer "+c.Token)
resp, err := c.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
return result, nil
}
func (c *ManagerRestClient) RegisterBackend(applicationID, callbackURL, version string) (map[string]interface{}, error) {
body := map[string]string{
"application_id": applicationID,
"callback_url": callbackURL,
"version": version,
}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", c.BaseURL+"/api/machine/register-backend", bytes.NewBuffer(jsonBody))
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
return result, nil
}See Also
- GraphQL API Integration - Primary API for resource management
- Backend Registration - Detailed trigger integration guide
- Machine Users - Service account authentication
- API Reference - Complete API documentation