Skip to content

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.com

Every 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.

http
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-Auth and (for user routes) the X-Token-* identity headers it derived from the OIDC session.
  • If PFY_PROXY_SECRET is unset on the Manager, the gate fails closed and every one of these routes answers 401; see Configuration. The single exception is the development escape hatch (PFY_ENV=development andPFY_DEV_USER_OVERRIDE_ENABLED).
  • The 401 carries a JSON body whose error field says which of the two it was: no secret configured on the Manager, or a missing/wrong X-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.

RouteAuthPurpose
GET /healthznone (separate health port)Liveness. Always 200 {"status":"healthy"}.
GET /readyznone (separate health port)Readiness. 200 {"status":"ready"}, or 503 when the database is unreachable.
GET/HEAD /healthnone (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-backendmachine userRegister a backend for trigger callbacks.
GET /api/machine/checkmachine userVerify a machine-user credential.
PUT /api/machine/backend/:id/heartbeatmachine userLiveness beat for a registration.
DELETE /api/machine/backend/:idmachine userGraceful deregistration.
GET /api/machine/application/:id/tenantsmachine userTenants that have the application enabled.
POST /api/machine/comms/emitmachine userEmit a durable notification.
POST /api/machine/comms/publishmachine userPublish a lossy live-data hint.
ANY /esb/:applicationId/*endpointPathmachine userEnterprise Service Bus forwarding.
ANY /queryproxy secret → PAT → user identityThe GraphQL API.
GET /gqldevelopment onlyGraphQL playground. Registered only when PFY_ENV=development.
GET/POST/DELETE /pilets/:projectId/...proxy secret → PAT → user identityPilet package list, download, upload, delete.
GET /api/audit/exportproxy secret → PAT → system administratorSIEM audit export.
GET /api/audit/evidenceproxy secret → PAT → system administratorLists the registered compliance-evidence query names.
GET /api/audit/evidence/:nameproxy secret → PAT → system administratorRuns 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/reviewproxy secret → PAT → system administratorAccess-review report.
POST /api/access/review/attestproxy secret → PAT → system administratorRecord an access-review attestation.
GET/POST /cli/authorizeproxy secret → user identityThe 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/startproxy secret onlyCLI 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/pollproxy secret onlyCLI polls for the sealed PAT. Delivers it exactly once; rate-limited per IP and per session with 429 + Retry-After.
POST /api/validate-proxy-authproxy secret onlyCredential validation for the proxy (see below).
POST /api/validate-oauth-userproxy secret onlyOAuth user validation for the proxy.
GET /api/frontend-config/:application_id/:tenant_idproxy secret onlyAuthoritative tenant-aware frontend config cascade.
GET /api/frontend-config/:application_idproxy secret onlyDeprecated tenant-less form; project + app defaults only.
GET /api/proxy/user/:user_id/tenantsproxy secret onlyTenant catalog for the proxy's tenant selector.
GET /api/proxy/comms/:appID/notificationsproxy secret + injected identityThe recipient inbox the bell/inbox pilet polls, newest-first and cursor-paginated.
GET /api/proxy/comms/:appID/unreadproxy secret + injected identityUnread probe. An EXISTS, not a count; the bell renders a dot.
POST /api/proxy/comms/:appID/notifications/readproxy secret + injected identityMark read, by ids or all.
POST /api/proxy/comms/:appID/notifications/seenproxy secret + injected identityMark seen, by ids only. all is refused.
GET /api/proxy/comms/:appID/wsproxy secret + injected identityThe comms websocket upgrade.
POST /api/proxy/wakeproxy secret onlyScale-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/readyproxy secret onlyScale-from-zero readiness for app_id + env. {"ready":bool,"backends":n}; ready:false is distinct from 404 (unknown app/env).
GET /api/proxy/deploymentproxy secret onlyScale-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/:localeproxy secret onlyLanguage pack download.
GET /pilet-feed/:applicationIdproxy secret onlyShared-vhost pilet feed.
GET /pilet-feed/:applicationId/:tenantIdproxy secret onlyDedicated-vhost feed, honouring that tenant's pilets.pins.
GET /pilet-bundle/:applicationId/:projectId/:name/:versionproxy secret onlyPilet 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/:applicationIdproxy secret onlyJWKS-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:

http
POST /api/validate-proxy-auth

Authentication: Proxy shared secret (requests not originating from the proxy are rejected)

Request Body:

json
{
  "authorization_header": "Bearer <token>",
  "application_id": "application-uuid"
}

Response (Success):

json
{
  "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):

json
{
  "valid": false,
  "message": "Invalid or expired authentication credentials"
}

Check Machine User Auth ​

Verifies the current machine user authentication status.

Endpoint:

http
GET /api/machine/check

Authentication: 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.

json
{
  "authenticated": true,
  "machine_user_id": "machine-user-uuid",
  "environment_id": "environment-uuid"
}

Example:

bash
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:

http
POST /api/machine/register-backend

Authentication: Bearer Token (Machine User) - Required

Request Body:

json
{
  "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:

json
{
  "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:

bash
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:

  • 200 with {"success": true, "registered_id": "backend-uuid", "message": "Heartbeat recorded"}
  • 404 with {"success": false, "message": "Backend registration not found"} when the id is unknown or not a UUID
  • 403 with {"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:

json
{
  "success": true,
  "tenants": [{ "id": "tenant-uuid", "name": "Acme" }]
}

Comms ​

Endpoints:

  • POST /api/machine/comms/emit emits a durable, per-user notification. Answers 202 with {"notification_id", "request_id", "recipient_count"}.
  • POST /api/machine/comms/publish publishes a lossy, at-most-once, tenant-wide live-data hint. Answers 202 with {"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 successful
  • 201 Created - Resource created successfully

Client Error Codes ​

  • 400 Bad Request - Invalid request format or parameters
  • 401 Unauthorized - Missing or invalid authentication
  • 403 Forbidden - Insufficient permissions
  • 404 Not Found - Resource not found
  • 429 Too Many Requests - Machine-auth throttle (/api/machine/*, /esb/*), CLI login polling, or comms rate limit; honour Retry-After where 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:

json
{
  "error": "Error message"
}

The registration endpoint reports errors in its response envelope instead:

json
{
  "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 ​

bash
#!/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:

javascript
// 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:

javascript
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:

python
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:

javascript
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 ​

javascript
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 ​

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 ​

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 ​