<!-- https://zerolog.ch/docs/api · Docs / Connect -->
# API reference

Call ZeroLog models from your own code with an OpenAI-compatible endpoint, and start work in your containers from other services with webhooks. 5 endpoints, all listed on this page.

- **Base URL**: `https://zerolog.ch`
- **Specification**: [/openapi.json](https://zerolog.ch/openapi.json) · OpenAPI 3.1.0
- **Version**: 1.0.0
- **Format**: JSON, UTF-8. Streams as server-sent events.

## Quickstart

You need a ZeroLog account. The token belongs to that account; every request you make with it is billed to your balance.

1. **Get your token.**

   Sign in at zerolog.ch, then open [`/api/cli/token`](https://zerolog.ch/api/cli/token) in the same browser. Copy the value of `token`. If you use ZeroLog Code, `zerolog login` fetches it for you.

   Your token, in the shell

   ```
export ZEROLOG_TOKEN="mhc-1042-REPLACE_WITH_YOUR_TOKEN"
```
2. **Check that it works.**

   A `200` with a model list means the token is valid. A `401` means it is not.

   List models

   ```
curl -sS https://zerolog.ch/api/cli/v1/models \
  -H "Authorization: Bearer $ZEROLOG_TOKEN"
```
3. **Send a request.**

   Any model your account may use works here. Without `model`, the catalogue default is used.

   Ask a model

   ```
curl -sS https://zerolog.ch/api/cli/v1/chat/completions \
  -H "Authorization: Bearer $ZEROLOG_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"model":"featherless/google/gemma-4-31b-it","messages":[{"role":"user","content":"Write a haiku about Swiss trains."}]}'
```

The endpoint speaks the OpenAI wire format, so OpenAI client libraries work if you set the base URL:

Python, OpenAI SDK

```
import os
from openai import OpenAI

client = OpenAI(
    base_url="https://zerolog.ch/api/cli/v1",
    api_key=os.environ["ZEROLOG_TOKEN"],
)
reply = client.chat.completions.create(
    model="featherless/google/gemma-4-31b-it",
    messages=[{"role": "user", "content": "Write a haiku about Swiss trains."}],
)
print(reply.choices[0].message.content)
```

## Authentication

Model endpoints take your client token in one of two headers:

- `Authorization: Bearer mhc-…`, which most clients send;
- `x-api-key: mhc-…`, for clients that send keys this way. If both are present, `x-api-key` wins.

The token has the form `mhc-<account>-<secret>`. It is derived from your account, does not expire, and stops working when the account is disabled. Treat it like a password: anyone holding it can spend your balance. You cannot revoke it on your own in the app yet. If it leaks, write to [info@zerolog.ch](mailto:info@zerolog.ch) and we revoke it.

Webhooks carry their own token in the URL (`mhw_…` or `mhin_…`). It is shown once when you create the webhook, stored only as a hash, and revoked by deleting the webhook. See [Webhooks](https://zerolog.ch/docs/webhooks).

## Errors

Model endpoints answer errors in the OpenAI form: `{"error": {"message": "…", "type": "…", "code": "…"}}`. `code` is set when there is a machine-readable reason. A missing or wrong token gets `{"type": "error", "error": {"type": "authentication_error", …}}`. Webhooks answer `{"error": "…", "code": "…"}`.

*Error statuses of the model endpoints*

| Status | Type | What happened | What to do |
| --- | --- | --- | --- |
| `400` | `invalid_request_error` | The request cannot be served as sent: no model and no default, or the provider rejected it. | Fix the request. Do not retry unchanged. |
| `401` | `authentication_error` | Missing, malformed or wrong token, or the account is disabled. | Check the token. Do not retry. |
| `402` | `billing_error` | Balance used up, or the shared pool is empty. | Top up in Billing, then retry. |
| `403` | `permission_error` | Your account may not use this model (`model_not_available`), or no usable key exists for it (`no_key`; for `byok/…` models `byok_key_invalid`). | Pick another model, or fix your own provider key. |
| `404` | `not_found_error` | The model provider does not know the model. `upstream_status` carries its status. | Pick another model. |
| `408 · 409 · 413 · 422` | `invalid_request_error` | The provider answered with this client error, passed on unchanged. A `413` can also come from ZeroLog itself (body over 32 MB). | Read `error.message`; change the request. |
| `429` | `rate_limit_error` | The provider is rate-limiting. ZeroLog passes on `Retry-After` when the provider sent one. | Wait, then retry with backoff. |
| `503` | `api_error` | No provider could answer, inference on this provider is paused for customers (`provider_temporarily_paused`), or the provider has no working key on our side (`provider_unavailable`). | Retry later or pick another model. |

A provider's `401` or `403` concerns ZeroLog's key, not yours, so you get `503` instead and your client keeps its token. When a provider answered, its status is in `error.upstream_status` and in the `X-ZeroLog-Upstream-Status` header.

**Fallback instead of failure.** If the model you asked for fails, ZeroLog may answer with another one. You can tell from the `X-ZeroLog-Served-Model`, `X-ZeroLog-Rerouted-From` and `X-ZeroLog-Model-Notice` headers, and from a one-line notice at the start of the answer. Send `X-ZeroLog-Reroute: off` when you would rather get the error.

## Rate limits and sizes

- **Model endpoints** have no fixed request rate. What limits you is your balance (`402`) and the provider's own rate limits (`429` with `Retry-After` when the provider sends it). Request bodies may be up to 32 MB.
- **Goal webhooks**: 1 to 60 triggers per minute (default 6) and 1 to 1000 per day (default 100), set per webhook. Bodies up to 32 kB.
- **Inject webhooks**: 1 to 120 events per minute (default 60), set per webhook. Bodies up to 256 kB.

Webhook limits answer `429` with `retry_after_s` in the body. Back off for that long.

## Pagination

None of the public endpoints page their results. `GET /api/cli/v1/models` returns its whole list in one response.

## Streaming

Send `"stream": true` to receive the answer as `text/event-stream`. Each `data:` line is one chunk in OpenAI form; the stream ends with `data: [DONE]`. ZeroLog asks the provider for a final chunk with `usage`. If you close the connection early, ZeroLog stops the provider request and bills what was produced up to then.

Streamed answer (shortened)

```
data: {"id":"chatcmpl-example-2","object":"chat.completion.chunk","model":"featherless/google/gemma-4-31b-it","choices":[{"index":0,"delta":{"content":"Lake Geneva"},"finish_reason":null}]}

data: {"id":"chatcmpl-example-2","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":21,"completion_tokens":9,"total_tokens":30}}

data: [DONE]
```

## Webhooks and WebSockets

ZeroLog does not call your servers. The two webhooks are inbound: you give their URL to GitHub, Stripe, Zapier or your own service, and they call ZeroLog. A goal webhook starts the goal run it was created for. An inject webhook writes each event as a JSON line into `/work/.webhooks/incoming.jsonl` in its container, where an agent can read it. Both accept only `POST`; any other method gets `405`, so a link opened in a browser starts nothing. Before an event is stored, `Authorization`, `Cookie` and other credential headers are replaced by `‹redacted›`.

There are no public WebSocket endpoints. Terminals, the browser extension and the desktop link use WebSockets inside the signed-in app; they are not part of this API.

## Endpoints

Every endpoint below is also in [/openapi.json](https://zerolog.ch/openapi.json), so you can load it into Postman, Insomnia or a code generator. The examples use invented data.

### Models

OpenAI-compatible inference. Billed to your ZeroLog balance, or to your own provider key for `byok/…` models.

### GET `/api/cli/v1/models`

List models

OpenAI-shaped model list for clients that probe `/models`. It returns the model your account resolves to by default, if your account may use it. The list can be empty; you can still send any model id from the catalogue that your account may use. It is not the full catalogue.

- **Authentication**: Client token: `Authorization: Bearer mhc-…` or `x-api-key`

*Responses*

| Status | Meaning |
| --- | --- |
| `200` | Model list. |
| `401` | Missing, malformed or wrong client token, or the account is disabled. A request without a token in the expected form and with a body over 64 kB is refused before it is read, with `{"error": "unauthorized"}`. |

curl

```
curl -sS "https://zerolog.ch/api/cli/v1/models" \
  -H "Authorization: Bearer $ZEROLOG_TOKEN"
```

JavaScript

```
// Node 18+ as an ES module (example.mjs), or a browser console
const res = await fetch(`https://zerolog.ch/api/cli/v1/models`, {
  method: "GET",
  headers: { Authorization: `Bearer ${process.env.ZEROLOG_TOKEN}` },
});
console.log(res.status, await res.json());
```

Python

```
import os, requests

res = requests.get(
    f"https://zerolog.ch/api/cli/v1/models",
    headers={"Authorization": f"Bearer {os.environ['ZEROLOG_TOKEN']}"},
)
print(res.status_code, res.json())
```

Response 200

```
{
  "object": "list",
  "data": [
    {
      "id": "featherless/google/gemma-4-31b-it",
      "object": "model",
      "created": 0,
      "owned_by": "zerolog"
    }
  ]
}
```

### POST `/api/cli/v1/chat/completions`

Create a chat completion

OpenAI-compatible chat completions. The request body is passed to the model provider in OpenAI form, so the usual fields (`messages`, `tools`, `tool_choice`, `temperature`, `max_tokens`, …) work. Only `model`, `messages` and `stream` are interpreted by ZeroLog itself.

**Model.** Without `model`, the operator's catalogue default is used. A model your account may not use is refused with `403 model_not_available` before any provider is called. `byok/<provider>/<model>` runs on your own provider key and never falls back to the shared pool.

**Fallback.** If the chosen model fails upstream, ZeroLog may answer with another model. It then sets the `X-ZeroLog-Served-Model`, `X-ZeroLog-Rerouted-From` and `X-ZeroLog-Model-Notice` headers and puts a one-line `[ZeroLog] … is unavailable` notice at the start of the answer. Send `X-ZeroLog-Reroute: off` to get the error instead.

**Streaming.** With `"stream": true` the answer is `text/event-stream` in OpenAI chunk format, ending with `data: [DONE]`. ZeroLog asks the provider for a final usage chunk.

**Billing.** Each answer is billed to your balance (or your own key for `byok/…`). A stream that the client aborts is still billed for what was produced.

- **Authentication**: Client token: `Authorization: Bearer mhc-…` or `x-api-key`
- **Parameters**: - `X-ZeroLog-Reroute` header — `off` disables the automatic fallback to another model. You then get the provider's error instead of a different model.
- **Request body**: OpenAI chat completions request. Maximum body size 32 MB. - **`model` string**: Model id. Optional; without it the catalogue default is used. - **`messages` array, required**: - **`stream` boolean**: - **`tools` array**: OpenAI tool definitions, passed to the provider. - **`tool_choice` any**: Passed to the provider. - **`temperature` number**: - **`max_tokens` integer**:

*Responses*

| Status | Meaning |
| --- | --- |
| `200` | The completion. JSON without `stream`, an event stream with `"stream": true`. |
| `400` | No model given and no default configured, the body is not valid JSON (then `{"error": "…"}` as a plain string), or the provider rejected the request (`upstream_status` is then set). |
| `401` | Missing, malformed or wrong client token, or the account is disabled. A request without a token in the expected form and with a body over 64 kB is refused before it is read, with `{"error": "unauthorized"}`. |
| `402` | Your balance is used up, or the shared pool is empty. |
| `403` | Your account may not use this model (`model_not_available`), or no usable key is available for it (for `byok/…`: your own key is missing or invalid). |
| `404` | The provider answered with a client error, passed on unchanged (404, 408, 409, 422 and other 4xx). `upstream_status` carries the provider's status. |
| `413` | The request body is larger than this endpoint accepts, or the provider refused it as too large (`upstream_status` is then set). |
| `429` | The provider is rate-limiting. Retry after the time in `Retry-After` when present. |
| `503` | No provider could answer (provider down, timed out or refused our key), or customer inference on this provider is paused (`provider_temporarily_paused`), or the provider behind the model has no working key on our side (`provider_unavailable`). 401 and 403 from a provider are reported as 503, because they concern ZeroLog's key and not yours. |
| `4XX` | The provider answered with a client error, passed on unchanged (404, 408, 409, 422 and other 4xx). `upstream_status` carries the provider's status. |

curl

```
curl -sS -X POST "https://zerolog.ch/api/cli/v1/chat/completions" \
  -H "Authorization: Bearer $ZEROLOG_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"model":"featherless/google/gemma-4-31b-it","messages":[{"role":"user","content":"Write a haiku about Swiss trains."}]}'
```

JavaScript

```
// Node 18+ as an ES module (example.mjs), or a browser console
const res = await fetch(`https://zerolog.ch/api/cli/v1/chat/completions`, {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.ZEROLOG_TOKEN}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    "model": "featherless/google/gemma-4-31b-it",
    "messages": [
      {
        "role": "user",
        "content": "Write a haiku about Swiss trains."
      }
    ]
  }),
});
console.log(res.status, await res.json());
```

Python

```
import os, requests

res = requests.post(
    f"https://zerolog.ch/api/cli/v1/chat/completions",
    headers={"Authorization": f"Bearer {os.environ['ZEROLOG_TOKEN']}"},
    json={
      "model": "featherless/google/gemma-4-31b-it",
      "messages": [
        {
          "role": "user",
          "content": "Write a haiku about Swiss trains."
        }
      ]
    },
)
print(res.status_code, res.json())
```

Response 200

```
{
  "id": "chatcmpl-example-1",
  "object": "chat.completion",
  "created": 1793700000,
  "model": "featherless/google/gemma-4-31b-it",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "Rails through the pass\nsnow on the windows, warm tea\nthe clock is on time"
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 16,
    "completion_tokens": 19,
    "total_tokens": 35
  }
}
```

### Authentication

Getting the client token for the endpoints above.

### GET `/api/cli/token`

Get your client token

Returns the client token of the signed-in account and the base URL to use it with. Needs a browser session on zerolog.ch: open the address while signed in, or let ZeroLog Code fetch it with `zerolog login`.

The token is derived from your account. It does not expire. It stops working when the account is disabled. It cannot be revoked on its own from the app today; if it leaks, write to info@zerolog.ch.

- **Authentication**: Browser session (signed in at zerolog.ch)

*Responses*

| Status | Meaning |
| --- | --- |
| `200` | Your token and base URL. |
| `401` | Not signed in, or the account is disabled. |
| `403` | The session may not do this: for example a session derived from a device token, or a paused account. `code` says which. |

Open this address in a browser where you are signed in. Scripts cannot use it: they would need your session cookie.

Response 200

```
{
  "token": "mhc-1042-REPLACE_WITH_YOUR_TOKEN",
  "base_url": "https://zerolog.ch/api/cli/v1",
  "note": "Bearer token for the zerocode terminal client. Pick the model in zerocode; it is sent as body.model."
}
```

### Webhooks

Inbound webhooks. You create them in the app (Automation and container settings); the token in the URL is the only credential and is shown once.

### POST `/api/hook/{token}`

Trigger a goal webhook

Starts the goal run that the webhook was created for, in its chat and container. Created in the app; the token (`mhw_…`) is shown once and stored only as a hash.

The call returns `202` as soon as the run is queued. `status_url` points to the job in the app and needs your browser session; it is not a public endpoint.

Every method other than POST answers `405`, so that opening the link in a browser never starts a paid run. Maximum body size 32 kB.

**Limits** are set per webhook: 1–60 triggers per minute (default 6) and 1–1000 per day (default 100). The account needs the goal mode permission and credit.

- **Authentication**: The token in the URL. No header.
- **Parameters**: - `token` path, required — Webhook token, `mhw_` followed by 32 URL-safe characters.
- **Request body**: JSON. - **`prompt` string**: Appended to the stored objective when the webhook is in append mode; ignored otherwise. Longer text is cut at 4000 characters.

*Responses*

| Status | Meaning |
| --- | --- |
| `202` | Run queued. |
| `400` | The webhook's chat and container are no longer attached to each other (`not_attached`), or the body is not valid JSON (then without `code`). |
| `402` | No prepaid credit left on the account. |
| `403` | The account is disabled or lacks the goal mode permission. |
| `404` | Unknown or revoked token (`no_hook`). A wrong token and a revoked one get the same answer, so tokens cannot be probed. `no_chat` and `no_container` mean the webhook still exists but its chat or container is gone. |
| `405` | Any method other than POST. |
| `409` | The container could not be resumed for the run. |
| `413` | Body larger than this webhook accepts (32 kB for goal webhooks, 256 kB for inject webhooks). |
| `429` | Per-minute (`rate_min`) or daily (`rate_day`) limit of this webhook reached. Wait `retry_after_s` seconds. |
| `500` | Unexpected server error. The body never carries internal details. |

curl

```
curl -sS -X POST "https://zerolog.ch/api/hook/$ZEROLOG_HOOK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"prompt":"Deploy finished for commit 3f2a91c. Check the release notes."}'
```

JavaScript

```
// Node 18+ as an ES module (example.mjs), or a browser console
const res = await fetch(`https://zerolog.ch/api/hook/${process.env.ZEROLOG_HOOK_TOKEN}`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    "prompt": "Deploy finished for commit 3f2a91c. Check the release notes."
  }),
});
console.log(res.status, await res.json());
```

Python

```
import os, requests

res = requests.post(
    f"https://zerolog.ch/api/hook/{os.environ['ZEROLOG_HOOK_TOKEN']}",
    json={
      "prompt": "Deploy finished for commit 3f2a91c. Check the release notes."
    },
)
print(res.status_code, res.json())
```

Response 202

```
{
  "job_id": 5821,
  "status_url": "/api/jobs/5821"
}
```

### POST `/api/inject/{token}`

Deliver an event into a container

Appends the request as one JSON line to `/work/.webhooks/incoming.jsonl` in the container the webhook belongs to. Point GitHub, Stripe, Zapier or your own service at this URL; an agent in the container can then read the events. The token (`mhin_…`) is shown once when you create the webhook.

Stored per event: time, method, request headers and the parsed JSON body. `Authorization`, `Cookie`, `X-Api-Key`, `X-Auth-Token` and `Proxy-Authorization` are replaced by `‹redacted›` before storage.

`202` is returned for every accepted event. `ok: false` with `status: "error"` means the event was counted but could not be written, usually because the container is not running.

Every method other than POST answers `405`. Maximum body size 256 kB. **Limit** per webhook: 1–120 events per minute (default 60).

- **Authentication**: The token in the URL. No header.
- **Parameters**: - `token` path, required — Webhook token, `mhin_` followed by 32 URL-safe characters.
- **Request body**: JSON.

*Responses*

| Status | Meaning |
| --- | --- |
| `202` | Event accepted. |
| `400` | The webhook's chat and container are no longer attached to each other (`not_attached`), or the body is not valid JSON (then without `code`). |
| `404` | Unknown or revoked token (`no_hook`). A wrong token and a revoked one get the same answer, so tokens cannot be probed. `no_chat` and `no_container` mean the webhook still exists but its chat or container is gone. |
| `405` | Any method other than POST. |
| `413` | Body larger than this webhook accepts (32 kB for goal webhooks, 256 kB for inject webhooks). |
| `429` | Per-minute (`rate_min`) or daily (`rate_day`) limit of this webhook reached. Wait `retry_after_s` seconds. |
| `500` | Unexpected server error. The body never carries internal details. |

curl

```
curl -sS -X POST "https://zerolog.ch/api/inject/$ZEROLOG_INJECT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ref":"refs/heads/main","after":"3f2a91c","repository":{"full_name":"example-org/example-repo"}}'
```

JavaScript

```
// Node 18+ as an ES module (example.mjs), or a browser console
const res = await fetch(`https://zerolog.ch/api/inject/${process.env.ZEROLOG_INJECT_TOKEN}`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    "ref": "refs/heads/main",
    "after": "3f2a91c",
    "repository": {
      "full_name": "example-org/example-repo"
    }
  }),
});
console.log(res.status, await res.json());
```

Python

```
import os, requests

res = requests.post(
    f"https://zerolog.ch/api/inject/{os.environ['ZEROLOG_INJECT_TOKEN']}",
    json={
      "ref": "refs/heads/main",
      "after": "3f2a91c",
      "repository": {
        "full_name": "example-org/example-repo"
      }
    },
)
print(res.status_code, res.json())
```

Response 202

```
{
  "ok": true,
  "status": "stored",
  "path": "/work/.webhooks/incoming.jsonl"
}
```

## Trying requests

This page has no "Try it" button. A live test would need a real token, and every call is billed, so a button in a public page would either spend someone's balance or need a sandbox account that does not exist yet. Copy the examples instead and run them with your own token.

## What this API does not cover

The rest of `/api/` belongs to the ZeroLog web app. It needs a browser session, changes without notice and is not meant for scripts. If you need something from there as a stable endpoint, tell us at [info@zerolog.ch](mailto:info@zerolog.ch). The Monitoring API is a separate public API with its own specification; both are listed in `/.well-known/api-catalog`.

The specification is checked against the server code on every deploy: a public endpoint that changes without its documentation stops the deploy.
