# API

This reference describes the **HTTP API** of SynthEvents: its **routes**, what they expect and answer, how **errors** look, and which **limits** apply.

## Base URL

SynthEvents is public at **`https://api.synthevents.io`**. Every route lives under the base path:

```text
/api/v1
```

The version is part of the path, so that a breaking change can come as a new version while existing clients keep working.

## Authentication

Reading the **ready-made data sets** and the **domains** needs **no token**. **[Generating](#generating-events)** a data set with parameters of your own takes a **token**, which you send in the header `Authorization`, after the scheme `Bearer`:

```http
Authorization: Bearer <TOKEN>
```

A token is issued for an occasion, such as a workshop or a talk, and names the occasion, not a person. It is valid until midnight in Berlin after the last day of the occasion. To get one, ask for a **[tailor-made data set](/contact?topic=synthevents-tailor-made-data-set)**: we send you a token and the command that generates it.

SynthEvents checks the token **before the body**, and refuses a request without a valid one with **`401 Unauthorized`** and the header `WWW-Authenticate: Bearer`. A token that has expired is refused with **[`token-expired`](/docs/synthevents/errors/token-expired)**, any other problem with it with **[`token-missing`](/docs/synthevents/errors/token-missing)** or **[`token-invalid`](/docs/synthevents/errors/token-invalid)**.

## Routes

The API has four routes:

| Method | Route | Purpose |
|---|---|---|
| `QUERY` | `/api/v1/read-data-set` | Hands out a ready-made data set. |
| `POST` | `/api/v1/generate-events` | Generates a data set, with a token. |
| `QUERY` | `/api/v1/read-domains` | Lists the domains with their scenarios and their ready-made data sets. |
| `GET`, `POST` | `/api/v1/ping` | Tells that the service is alive. |

Request bodies are **JSON**. A request for data that SynthEvents keeps uses the HTTP method **`QUERY`**, a request to produce something uses **`POST`**.

### Reading Ready-Made Data Sets

SynthEvents keeps **ready-made data sets**: every scenario of every domain in three sizes, of about **500**, **5,000**, and **20,000** events, over the year **2025**, with the seed `standard`. They are **the same for everyone**, as long as SynthEvents keeps its version, and **[`read-domains`](#reading-domains)** lists them by name. To fetch one, send a **`QUERY`** request to **`/api/v1/read-data-set`**:

```shell
curl \
  -X QUERY \
  -H "content-type: application/json" \
  -d '{
    "name": "library-everyday-medium",
    "format": "eventsourcingdb"
  }' \
  -OJ \
  https://api.synthevents.io/api/v1/read-data-set
```

The body is a JSON object with **exactly these fields**, both of them mandatory:

| Field | Type | Description |
|---|---|---|
| `name` | string | The name of a ready-made data set, made of its domain, scenario, and size, such as `library-everyday-medium`. |
| `format` | string | `cloudevents` for plain CloudEvents, or `eventsourcingdb` for a backup for EventSourcingDB. |

A ready-made data set is the data set that **[generating](#generating-events)** its parameters gives, so SynthEvents answers with the same body and the same headers: the same bytes, the same `ETag`, and the same file name. It keeps the ready-made data sets from the moment it starts, so it answers at once, and never refuses such a request as busy.

### Generating Events

To generate a data set, send a **`POST`** request to **`/api/v1/generate-events`**, with your **[token](#authentication)** in place of `<TOKEN>`:

```shell
curl \
  -X POST \
  -H "authorization: Bearer <TOKEN>" \
  -H "content-type: application/json" \
  -d '{
    "domain": "library",
    "scenario": "everyday",
    "range": {
      "from": "2025-01-01T00:00:00Z",
      "to": "2026-01-01T00:00:00Z"
    },
    "eventCount": 20000,
    "seed": "ddd-workshop-2026",
    "format": "eventsourcingdb"
  }' \
  -OJ \
  https://api.synthevents.io/api/v1/generate-events
```

The body is a JSON object with **exactly these fields**, all of them mandatory:

| Field | Type | Description |
|---|---|---|
| `domain` | string | The domain, `courses`, `library`, or `todo`. |
| `scenario` | string | A scenario of the domain, such as `everyday`. |
| `range.from` | string | The start of the range, in RFC 3339 with an offset, on or after `1970-01-01T00:00:00Z`. |
| `range.to` | string | The end of the range, in RFC 3339 with an offset, after `range.from`, and on or before the moment of the request. |
| `eventCount` | integer | The approximate number of events, from `1` to `20000`. |
| `seed` | string | The seed, 1 to 64 characters of `a` to `z`, `0` to `9`, and `-`. |
| `format` | string | `cloudevents` for plain CloudEvents, or `eventsourcingdb` for a backup for EventSourcingDB. |

The **range is half-open**: it includes `from`, but not `to`. Field names are **case-sensitive**, so a field such as `eventcount` is refused as unknown rather than read as `eventCount`. A request names **no event type prefix**: every event type is `io.synthevents.<domain>.<event>`, and a field such as `eventTypePrefix` is refused like any other unknown field. For what the parameters do, see **[Generating Data Sets](/docs/synthevents/generating-data-sets)**.

If the request is valid, SynthEvents answers with **`200 OK`**, and the **body is the data set itself**, as NDJSON, without an envelope (see **[Formats](/docs/synthevents/formats)**). SynthEvents generates the data set in full before it sends the first byte, so a failure always comes as a status code, never as a truncated file. The response has these headers:

- **`Content-Type`** is `application/x-ndjson`.
- **`Content-Length`** is the size of the data set in bytes.
- **`ETag`** is the **SHA-256 hash** of the body, in hexadecimal. It is a strong tag, since the same request always gives the same bytes.
- **`Content-Disposition`** names the file after its domain, scenario, the days of its range in UTC, the number of events asked for, the seed, and the format, such as:

```http
Content-Disposition: attachment; filename="synthevents-library-everyday-2025-01-01-2026-01-01-20000-ddd-workshop-2026.eventsourcingdb.ndjson"
```

The option `-OJ` of curl saves the data set under that name. The name leaves out the version of SynthEvents and the times of the range, so two data sets can share a name, such as two that were generated by different versions. Their `source` tells them apart.

### Reading Domains

To list the domains and their scenarios, send a **`QUERY`** request to **`/api/v1/read-domains`**, with an **empty JSON object** as its body:

```shell
curl \
  -X QUERY \
  -H "content-type: application/json" \
  -d '{}' \
  https://api.synthevents.io/api/v1/read-domains
```

The response is a stream of **NDJSON** with the content type `application/x-ndjson`. It has one line of the type `domain` for every domain, in the order of their names, with its scenarios, also in the order of their names. A line of the type `end` follows the last domain, so that you can tell that the stream is complete:

```json
{"type":"domain","data":{"name":"courses","scenarios":[{"name":"everyday","dataSets":[{"name":"courses-everyday-small","range":{"from":"2025-01-01T00:00:00Z","to":"2026-01-01T00:00:00Z"},"eventCount":500,"seed":"standard"},{"name":"courses-everyday-medium","range":{"from":"2025-01-01T00:00:00Z","to":"2026-01-01T00:00:00Z"},"eventCount":5000,"seed":"standard"},{"name":"courses-everyday-large","range":{"from":"2025-01-01T00:00:00Z","to":"2026-01-01T00:00:00Z"},"eventCount":20000,"seed":"standard"}]}]}}
{"type":"domain","data":{"name":"library","scenarios":[{"name":"everyday","dataSets":[{"name":"library-everyday-small","range":{"from":"2025-01-01T00:00:00Z","to":"2026-01-01T00:00:00Z"},"eventCount":500,"seed":"standard"},{"name":"library-everyday-medium","range":{"from":"2025-01-01T00:00:00Z","to":"2026-01-01T00:00:00Z"},"eventCount":5000,"seed":"standard"},{"name":"library-everyday-large","range":{"from":"2025-01-01T00:00:00Z","to":"2026-01-01T00:00:00Z"},"eventCount":20000,"seed":"standard"}]}]}}
{"type":"domain","data":{"name":"todo","scenarios":[{"name":"everyday","dataSets":[{"name":"todo-everyday-small","range":{"from":"2025-01-01T00:00:00Z","to":"2026-01-01T00:00:00Z"},"eventCount":500,"seed":"standard"},{"name":"todo-everyday-medium","range":{"from":"2025-01-01T00:00:00Z","to":"2026-01-01T00:00:00Z"},"eventCount":5000,"seed":"standard"},{"name":"todo-everyday-large","range":{"from":"2025-01-01T00:00:00Z","to":"2026-01-01T00:00:00Z"},"eventCount":20000,"seed":"standard"}]}]}}
{"type":"end","data":{}}
```

Every scenario is an object, so that it can carry more information later without a change to the shape of the response. Under **`dataSets`**, it lists its **[ready-made data sets](#reading-ready-made-data-sets)** in the order of their size, each with its name and the parameters it is made of, named as in a request to generate it: its range in UTC, its number of events, and its seed. The domains and their scenarios are described in **[Library](/docs/synthevents/library)** and **[To-Dos](/docs/synthevents/todo)**.

The body must be exactly `{}`. A body with any field, such as `{"domain": "library"}`, is refused, rather than taken for a filter.

### Pinging

To check whether SynthEvents is alive, send a **`GET`** or **`POST`** request to **`/api/v1/ping`**:

```shell
curl https://api.synthevents.io/api/v1/ping
```

SynthEvents answers with an event of its own, wrapped in an envelope of the type `event`, with the content type `application/json`:

```json
{"type":"event","data":{"specversion":"1.0","id":"01a1417f-457a-7af1-92eb-0d85cee3ca12","source":"tag:synthevents.io,2026:synthevents","type":"io.synthevents.api.ping-received","subject":"/api/v1/ping","time":"2026-10-15T21:37:02.330692Z","datacontenttype":"application/json","data":{"message":"My God, it's full of stars."}}}
```

Its `id` is a UUIDv7, and its `time` is the moment of the request.

## Errors

If SynthEvents refuses a request, it answers with a **real HTTP status code** and a JSON body, with the content type `application/json`:

```json
{
  "type": "error",
  "data": {
    "code": "https://docs.synthevents.io/errors/seed-malformed",
    "description": "seed is malformed: expected 1 to 64 of a-z, 0-9, and -",
    "details": {
      "value": "DDD Workshop 2026"
    }
  }
}
```

- **`code`** is a URL that ends in the **slug** of the error, such as `seed-malformed`. It leads to the page of the error in this documentation.
- **`description`** says **what is wrong** and **what is expected**.
- **`details`** locates the problem, such as by the value or the field to blame. It is an empty object if there is nothing to locate.

The status codes are these:

| Status | Meaning |
|---|---|
| `400 Bad Request` | The body is not JSON, or does not have the expected shape, such as because a field is missing or unknown. |
| `401 Unauthorized` | The request to generate events has no valid token. The response has a `WWW-Authenticate` header. |
| `413 Request Entity Too Large` | The body is larger than any request needs. |
| `422 Unprocessable Entity` | The body has the expected shape, but a value is not valid, or the range and the number of events do not fit together. |
| `500 Internal Server Error` | SynthEvents failed itself. |
| `503 Service Unavailable` | SynthEvents is busy generating. The response has a `Retry-After` header. |

SynthEvents checks the token before the body, and the shape of a body before its values, and reports **one error at a time**. For every error, its cause, and how to fix it, see **[Errors](/docs/synthevents/errors)**.

A request to a route that does not exist is answered with **`404 Not Found`**, and one with a method that a route does not take with **`405 Method Not Allowed`**. Both come with a body in plain text rather than in the shape of an error, since the status code says everything there is to say.

## Limits

- A body may have at most **65,536 bytes** (64 KiB), far more than any valid request needs. A larger one is refused with **[`body-too-large`](/docs/synthevents/errors/body-too-large)**.
- SynthEvents generates **one data set per processor core** at a time, and at least two. A request beyond that is refused with **[`service-busy`](/docs/synthevents/errors/service-busy)** and **`Retry-After: 3`**. A request that is refused for another reason does not count, and neither does one that is answered from the cache, nor one for a ready-made data set.
- SynthEvents generates its **ready-made data sets when it starts**, before it answers anything, and keeps them for as long as it runs.
- SynthEvents keeps the data sets it has generated in a **cache**. A request for a data set that was generated recently is answered from it, and identical requests that arrive at the same time are generated only once.
- SynthEvents does not limit the rate of requests. Generating takes a **[token](#authentication)** instead.
- SynthEvents sends **no CORS headers**, so a script on a website cannot read its responses in a browser. Call it from a server or from the command line instead.
