# PoisonZero Admin API (v1)

Manage your daemon fleet programmatically: create app IDs with enrollment
codes, list your fleet, deactivate or delete apps. Built for scripts and AI
agents — one static API key, plain JSON, no SDK required.

Base URL: `https://poisonzero.com/api`
OpenAPI spec: `https://poisonzero.com/docs/api/openapi.json`
HTML docs: `https://poisonzero.com/docs/api`

## Authentication

Create an API key in the panel at <https://console.poisonzero.com/settings>
(Settings → API keys). The key (`pz_live_…`) is shown once — store it safely.
Keys are scoped to your account and can be revoked in the panel at any time.

Send the key as a Bearer token with every request:

    curl https://poisonzero.com/api/v1/apps \
      -H "Authorization: Bearer pz_live_..."

## Endpoints

### POST /v1/apps

Creates an app and an enrollment code in one call.

Request body (optional): `{"name": "build-server-07"}` — max. 100 characters.

Response `201`:

    {
      "appId": "k7x2m9q4w1bz8r3tj6vn",
      "name": "build-server-07",
      "status": "pending",
      "enrollCode": "mq4xw7k2p9t3vz8c5n1rb6dh",
      "enrollCodeExpiresAt": "2026-07-04T16:00:00.000Z"
    }

Use `appId` + `enrollCode` to enroll the daemon on the target machine
via the one-line installer (see <https://poisonzero.com/download>):

    PZ_APP_ID=<appId> PZ_ENROLL_CODE=<enrollCode> sh -c "$(curl -fsSL https://poisonzero.com/install.sh)"

### GET /v1/apps

Lists your fleet. Response `200`:

    {
      "apps": [
        {
          "appId": "k7x2m9q4w1bz8r3tj6vn",
          "name": "build-server-07",
          "status": "active",
          "platform": "linux",
          "agentVersion": "0.3.0",
          "lastSeenAt": "2026-06-04T15:00:00.000Z",
          "createdAt": "2026-06-01T09:00:00.000Z"
        }
      ]
    }

`status` is one of `pending` (created, not yet enrolled), `active`
(daemon enrolled and allowed), `revoked` (deactivated).

### GET /v1/apps/{appId}

Returns a single app (same fields as the list). Unknown IDs — including apps
that belong to a different account — answer `404`.

### POST /v1/apps/{appId}/enroll-code

Creates a fresh enrollment code for an existing app (e.g. to re-provision a
machine). Codes are single-use and valid for 30 days. Response `201`:

    { "enrollCode": "mq4xw7k2p9t3vz8c5n1rb6dh", "expiresAt": "2026-07-04T16:00:00.000Z" }

### POST /v1/apps/{appId}/revoke

Deactivates an app: status becomes `revoked` and the daemon's credentials stop
working immediately. Response `200`:

    { "appId": "k7x2m9q4w1bz8r3tj6vn", "status": "revoked" }

### DELETE /v1/apps/{appId}

Deletes an app permanently: app, configuration, daemon identity, open
enrollment codes, and its review-queue entries. Audit logs are retained.
Response `204` (empty body).

### GET /v1/otel-config

Read the fleet-wide OpenTelemetry export configuration. **Enterprise-only** —
returns `403 enterprise_required` for accounts without an Enterprise
entitlement. The collector auth header is **write-only**: it is never
returned, the response only reports whether one is set. Response `200`:

    {
      "enabled": true,
      "endpoint": "https://otel-collector.internal:4318",
      "authHeaderSet": true,
      "evidenceLevel": "hashes"
    }

If no config has ever been stored, the same shape comes back with
`enabled: false`, `endpoint: ""`, `authHeaderSet: false`.

### PUT /v1/otel-config

Set the fleet-wide OTel export configuration. **Enterprise-only** (same
`403 enterprise_required` as GET). The body is fully validated before
anything is written — an invalid value returns `400`, nothing is stored.

| Field | Type | Notes |
|---|---|---|
| `enabled` | boolean | required |
| `endpoint` | string | required when `enabled` is `true`; must be `http:`/`https:`, must have a host, no query/fragment, no userinfo credentials, max 2048 chars. Plain `http:` is only accepted for loopback/private (LAN, `.local`, `localhost`, RFC1918/ULA) collectors — a public host must use `https:` |
| `authHeader` | string | **write-only.** Omit the field to keep the currently stored value; send `""` to delete it; send a value to set/replace it. Max 8192 chars. Never returned by GET |
| `evidenceLevel` | string | one of `hashes`, `redacted`, `full`; defaults to `hashes` if omitted |

Response `200` — the stored config in the same (secret-free) shape as GET.

```bash
curl -X PUT https://poisonzero.com/api/v1/otel-config \
  -H "Authorization: Bearer $PZ_API_KEY" -H "Content-Type: application/json" \
  -d '{"enabled":true,"endpoint":"https://otel-collector.internal:4318","authHeader":"Bearer <collector-token>","evidenceLevel":"hashes"}'
```

### DELETE /v1/otel-config

Remove the stored OTel configuration, including the underlying collector
secret in Secret Manager (not just the reference to it). Available to
**any owner** — this endpoint is not Enterprise-gated, so a downgraded
ex-Enterprise account can still purge its stored secret. Auth-required and
owner-scoped like every other endpoint (the owner is taken only from the
verified API key). Idempotent — deleting an already-empty config also
succeeds. Response `200`:

    { "deleted": true }

## Bulk provisioning

Each create call returns everything a machine needs to enroll:

    for i in $(seq -w 1 50); do
      curl -s -X POST https://poisonzero.com/api/v1/apps \
        -H "Authorization: Bearer $PZ_API_KEY" \
        -H "Content-Type: application/json" \
        -d "{\"name\": \"daemon-$i\"}"
    done

## Errors

All errors share one shape:

    { "error": { "code": "...", "message": "..." } }

| Status | Code | Meaning |
|---|---|---|
| 401 | `invalid_key` | missing, malformed, unknown, or revoked API key |
| 401 | `account_disabled` | your account is disabled |
| 404 | `not_found` | unknown endpoint, or unknown/foreign app ID |
| 400 | `invalid_name` | `name` is not a string or exceeds 100 characters |
| 400 | `limit_reached` | more than 1000 apps per account |
| 403 | `enterprise_required` | `/v1/otel-config` GET/PUT on an account without an Enterprise entitlement |
| 400 | `invalid_enabled` | `enabled` missing or not a boolean (PUT `/v1/otel-config`) |
| 400 | `invalid_endpoint` | `endpoint` missing, malformed, or fails the http/https/host/no-userinfo checks |
| 400 | `invalid_auth_header` | `authHeader` is not a string or exceeds 8192 characters |
| 400 | `invalid_evidence_level` | `evidenceLevel` is not one of `hashes`, `redacted`, `full` |
| 413 | `payload_too_large` | request body exceeds 64 KiB (65536 bytes) |
| 429 | `rate_limited` | more than 600 requests per minute per account — slow down and retry |
| 502 | `upstream_unavailable` | API temporarily unavailable — retry later |

## OpenTelemetry export setup (Enterprise)

The cloud OTel config above (`GET`/`PUT`/`DELETE /v1/otel-config`) steers
**cloud-managed** Enterprise fleets: set the collector endpoint and detail
level once, and every cloud-connected device picks it up on its next config
poll. **Private / on-prem daemons are configured locally instead** — see the
operator note shipped with the private-mode daemon (a local `otel.json`); the
cloud config above never reaches a private device, and detection events for
private deployments are exported daemon → your own collector without ever
passing through the PoisonZero cloud.

## Billing

Unchanged: active daemons are metered nightly (1 € per daemon per month),
no matter whether they were created in the panel or via this API.
