> For the complete documentation index, see [llms.txt](https://docs.hilomarket.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.hilomarket.com/api-developers.md).

# API / Developers

## Hilo Public API (v1)

Create and read Hilo prediction markets programmatically with the v1 REST API. Markets you create here go live immediately and follow the same rules, rewards and side effects as markets created in the web app.

> **Status: beta.** The endpoints below are live. The topic vocabulary is still being curated, so a name you expect may return `TOPIC_NOT_FOUND` — fetch `GET /api/v1/topics` for the accepted set.

**Base URL:** `https://hilo.app/api/v1`

***

### Quick start

```bash
curl -X POST https://hilo.app/api/v1/markets \
  -H "Authorization: Bearer $HILO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "title": "Will the Fed cut rates before July 2027?",
    "description": "Resolves YES if the FOMC announces a cut to the target range at or before the July 2027 meeting.",
    "options": ["Yes", "No"],
    "marketType": "event",
    "topics": ["Economy", "Finance"],
    "filterCountries": ["United States"],
    "estimatedEndDate": "2027-07-31T00:00:00.000Z"
  }'
```

### Authentication

Every request carries a key as a bearer token:

```
Authorization: Bearer hilo_sk_…
```

Create keys in **Settings → API Keys**. Premium and Premium+ only. The plaintext key is shown **once** at creation and never again — only a hash is stored. Lose it and you create a new one.

**Keep your key on a server.** Never ship it in a browser, a mobile app, or anything a user can read. A key acts as you: it creates markets under your account, spends your daily allowance, and earns your XP and HIQ. There is deliberately no CORS header on `/api/v1`, because a key should never be in a position to be sent from a page.

Your key only works while your subscription is active. If it lapses, the key stops working that day — it is not a standing grant.

#### Scopes

| Scope           | Grants           |
| --------------- | ---------------- |
| `markets:read`  | `GET` endpoints  |
| `markets:write` | `POST` endpoints |

Both are granted by default. Untick "Allow creating markets" when creating a key to get a read-only one. A request outside its scope returns `403 INSUFFICIENT_SCOPE` with the required and granted scopes in `details`.

***

### `POST /api/v1/markets`

Creates a market. It is live immediately — there is no approval queue, matching the web app.

#### Request

The JSON body must be 1 MB or smaller, and the request must include a `Content-Length` header.

| Field                 | Type                                        | Required | Notes                                                                  |
| --------------------- | ------------------------------------------- | -------- | ---------------------------------------------------------------------- |
| `title`               | string                                      | yes      | 1–120 characters                                                       |
| `description`         | string                                      | yes      | 1–5000 characters, used verbatim                                       |
| `options`             | string\[]                                   | yes      | 2–20; blanks are dropped before counting                               |
| `marketType`          | `"event"` \| `"opinion"` \| `"multiwinner"` | yes      |                                                                        |
| `topics`              | string\[]                                   | yes      | Non-empty, max 10. **Names, not ids.** First becomes the primary topic |
| `newTopic`            | string \| null                              | no       | Propose one topic that does not exist yet. See below                   |
| `filterCountries`     | string\[]                                   | no       | Full country **names**                                                 |
| `statisticsCountries` | string\[]                                   | no       | Full country **names**                                                 |
| `estimatedEndDate`    | ISO 8601 \| null                            | no       | Must be in the future, within 5 years. Ignored for `opinion`           |
| `locksAt`             | ISO 8601 \| null                            | no       | Must be ≤ `estimatedEndDate`                                           |
| `isMultiSelect`       | boolean                                     | no       | Premium+ only                                                          |
| `isNsfw`              | boolean                                     | no       |                                                                        |
| `isAnonymous`         | boolean                                     | no       |                                                                        |

#### Response — `201`

```json
{
  "success": true,
  "data": {
    "market": {
      "id": "…",
      "slug": "will-the-fed-cut-rates-before-july-2027-m1n2o3",
      "url": "https://hilo.app/market/will-the-fed-cut-rates-before-july-2027-m1n2o3"
    }
  }
}
```

#### Idempotency

Send an `Idempotency-Key` header — any client-generated string up to 255 characters. **Strongly recommended.** Without one, a request that times out and gets retried creates a second market.

* Same key replayed after success → the original `201` with an identical body, no second market.
* Same key while the first request is still in flight → `409 IDEMPOTENCY_CONFLICT`.
* Keys are remembered for 24 hours.
* A request that fails releases its key immediately, so you can retry at once.

***

### Topics

Topics are addressed **by name**, case-insensitively: `"NBA"`, `"nba"` and `"Nba"` are the same topic, and `"PREMIER LEAGUE"` resolves to `Premier league`.

**The first name in `topics` becomes the market's primary topic** — the one shown on the card. The rest are additional tags.

Only the **curated** vocabulary is accepted. Fetch it from `GET /api/v1/topics`; do not assume a name works because you saw it on the site, as topics auto-generated on existing markets are not addressable unless Hilo has curated them.

Common alternate names resolve automatically — send `Crypto` and the market is tagged `Cryptocurrency`. `GET /api/v1/topics` lists every such mapping under `aliases`, and it is the only place to discover them, because where an alias exists there is no error to tell you.

An unknown name returns `400 TOPIC_NOT_FOUND` naming the offending values.

#### Proposing a new topic

If a topic genuinely does not exist, send it as `newTopic`:

```json
{ "topics": ["Sports"], "newTopic": "Tennis" }
```

Rules, each of which matters:

* **`topics` must still carry at least one curated name.** A market tagged only with a proper noun nobody browses is invisible; the new topic is additive detail.
* **One per market**, and at most **3 per day**. Requests proposing a new topic are processed one at a time per user; a concurrent one gets `429 RATE_LIMITED` with a short `Retry-After`.
* 2–30 characters, letters/digits/spaces, not digits-only, no leading digit. A violation is `422 VALIDATION_ERROR`, like any other body error.
* It is resolved through the same chain as `topics` first, aliases included — so `newTopic: "cryptocurrency"` simply attaches the existing `Cryptocurrency` and creates nothing.
* If an uncurated topic already holds the name, the market is attached to that one instead. Nothing is created, and it does **not** count against your 3/day.
* Some names are permanently unavailable because a removed topic once held them. Those return `422 INVALID_TOPIC_NAME` — pick a different name.
* **The topic tags this market immediately, but is not addressable by name on your next request** until Hilo promotes it, and it will not appear in `GET /api/v1/topics`. After creating `Tennis`, for example, using `topics: ["Tennis"]` on your next market returns `400` until it is promoted.

***

### `GET /api/v1/topics`

The curated vocabulary, most-used first, plus the alias map. Cached for one hour.

```json
{
  "topics": ["Sports", "Football", "Soccer", "Politics", "…"],
  "aliases": { "Crypto": "Cryptocurrency", "Film": "Movies" }
}
```

This endpoint is authoritative. The vocabulary is **data, not contract** — it grows and shrinks as Hilo curates it, and that is not a breaking change.

***

### `GET /api/v1/markets/{idOrSlug}`

Reads a market by id or slug. Returns title, description, options, current odds, vote counts, topics, image, resolution state and timestamps. `404 MARKET_NOT_FOUND` otherwise.

### `GET /api/v1/me`

Your identity, tier, level/XP/HIQ/MIS, today's market count against your limit, the granted scopes, and the key's id, name and prefix.

It also returns `canCreateMarket` and, when false, `requirements[]` — the same shape `POST /api/v1/markets` returns on a `403`. **Call this before your first create** to confirm your account is eligible without a failed write.

### `POST /api/v1/markets/{idOrSlug}/image`

Attaches an image. `multipart/form-data`, field name `file`, max **5 MB**. A `Content-Length` header is required — requests without one are rejected with `411`. `curl -F` and mainstream HTTP clients send it automatically.

```bash
curl -X POST https://hilo.app/api/v1/markets/$SLUG/image \
  -H "Authorization: Bearer $HILO_API_KEY" \
  -F "file=@chart.png"
```

The format is detected from the file contents, not its name or declared type — a `.png` renamed to `.webp` is accepted, a PDF renamed to `.webp` is not. Images are re-encoded to WebP, EXIF-rotated and resized to fit 1920×1920.

Replacing an existing image requires being the market's creator.

***

### Limits

Market creation volume is governed by your **subscription tier**, shared with the web app — using both surfaces does not double your allowance:

| Tier     | Markets per day |
| -------- | --------------- |
| Free     | 1               |
| Premium  | 5               |
| Premium+ | Unlimited       |

Rate limits on top of that:

| Limit                                    | Window                |
| ---------------------------------------- | --------------------- |
| Per key                                  | 120 / minute          |
| Per IP                                   | 300 / minute          |
| Market creation                          | 5 / minute, 200 / day |
| Market creation (shared with the web UI) | 100 / hour            |
| Image upload                             | 20 / hour             |
| New topic                                | 3 / day               |

A `429` carries `Retry-After` in seconds.

#### Eligibility

Beyond the tier limit, market creation requires a minimum account age, level, MIS score and HIQ rating. These are configured platform-wide and apply to the API exactly as they do to the web UI. A `403 REQUIREMENTS_NOT_MET` lists each unmet requirement with your current value and the needed one. `GET /api/v1/me` gives the same answer without a failed write.

***

### Errors

Every error carries a stable machine-readable `code`:

```json
{
  "success": false,
  "error": "One or more topics were not found",
  "code": "TOPIC_NOT_FOUND",
  "details": { "topics": ["Tenis"] }
}
```

**Branch on `code`, never on `error`.** Codes are part of the contract; messages are not and may be reworded at any time.

| Code                     | HTTP | Meaning                                                               |
| ------------------------ | ---- | --------------------------------------------------------------------- |
| `MISSING_API_KEY`        | 401  | No `Authorization` header                                             |
| `INVALID_API_KEY`        | 401  | Unknown, revoked or expired                                           |
| `INSUFFICIENT_SCOPE`     | 403  | Key lacks the required scope                                          |
| `PREMIUM_REQUIRED`       | 403  | Subscription lapsed or never held                                     |
| `PREMIUM_PLUS_REQUIRED`  | 403  | `isMultiSelect` needs Premium+                                        |
| `ACCOUNT_DEACTIVATED`    | 403  |                                                                       |
| `ACTION_RESTRICTED`      | 403  | Account restricted from this action                                   |
| `DAILY_LIMIT_REACHED`    | 403  | Tier's daily market limit hit                                         |
| `REQUIREMENTS_NOT_MET`   | 403  | See `details.requirements`                                            |
| `NOT_MARKET_OWNER`       | 403  | Only the creator may replace an image                                 |
| `INVALID_OPTION_COUNT`   | 400  |                                                                       |
| `TOPIC_NOT_FOUND`        | 400  | See `details.topics`                                                  |
| `INVALID_IMAGE`          | 400  | Not a decodable image                                                 |
| `MARKET_NOT_FOUND`       | 404  |                                                                       |
| `USER_NOT_FOUND`         | 404  |                                                                       |
| `IDEMPOTENCY_CONFLICT`   | 409  | Same key still in flight                                              |
| `PAYLOAD_TOO_LARGE`      | 413  | Create body over 1 MB, or image over 5 MB                             |
| `UNSUPPORTED_MEDIA_TYPE` | 415  | Expected `multipart/form-data`                                        |
| `INVALID_TOPIC_NAME`     | 422  | `newTopic` name is permanently unavailable (a removed topic holds it) |
| `VALIDATION_ERROR`       | 422  | Request body invalid (`411` when `Content-Length` is missing)         |
| `RATE_LIMITED`           | 429  | See `Retry-After`                                                     |
| `INTERNAL_ERROR`         | 500  |                                                                       |
| `STORAGE_ERROR`          | 502  | Image storage unavailable                                             |
| `SERVICE_DISABLED`       | 503  | API temporarily disabled                                              |

***

### Good to know

1. **`description` is required and used verbatim.** Unlike the web UI, nothing writes one for you.
2. **Country fields take full names** — `"United States"`, not `"US"`. An unknown value is rejected rather than silently dropped.
3. **Opinion markets always open at equal odds.** Seeding your own opening percentages is not available, since it would let you set the odds on your own market.
4. **A topic you mint with `newTopic` is not resolvable by name on your next request** until Hilo curates it.
5. **Photo bonus XP earned via the image endpoint may not be included in the market-created notification's XP total.** You still earn it.
6. **One curated topic is unreachable by name** — `🏆 2026 World Cup` begins with an emoji and does not survive name normalization.

***

### Versioning

* **Additive changes ship in place.** New response fields, new optional request fields and new endpoints can appear in `v1` at any time. **Ignore unknown fields.**
* **Breaking changes get `/api/v2`.** Removing or renaming a field, tightening validation, or changing the status code for an existing condition is breaking. Error *codes* are part of the contract; error *messages* are not.
* **Deprecation:** a removed endpoint keeps serving for 90 days with `Deprecation` and `Sunset` headers, and key owners using it get one email.
* The topic vocabulary is data, not contract.

### Not in v1

Drafts, voting and resolution, webhooks, listing your own markets, and AI enrichment of submitted markets.

<br>
