# REST API

Base URL: `https://kaicast.com`. All endpoints are `GET`, return JSON, and allow cross-origin requests. The machine-readable spec is [`/openapi.json`](https://kaicast.com/openapi.json) (OpenAPI 3.1) — import it into Postman, generate a client, or hand it to an agent framework as a tool definition.

| Endpoint | Layer | Purpose |
|---|---|---|
| `GET /api/v1/conditions` | Fact | Conditions for one spot over up to 7 days |
| `GET /api/v1/spots` | Fact | Spot directory (metadata only), filter by region / text / nearest |
| `GET /api/v1/coverage` | Fact | Covered regions, decision support, activities |
| `GET /api/v1/recommendations` | Decision | Ranked spot picks for an activity, area and day |
| `GET /api/v1/best-day` | Decision | Best day in a window for an activity at a destination |

## Conventions

- **Attribution:** every JSON body — including errors — has a `source` object: `{ "name": "KaiCast", "url": "https://kaicast.com", "docs": "https://kaicast.com/developers", "citation": "…" }`. Cite it when you surface the data. Responses also carry a `Link` header pointing at these docs and `/openapi.json`.

- **Units are in field names** — `visibility_ft`, `swell_height_m`, `wind_speed_kt`, `water_temp_c`. No separate unit strings to lose.
- **Directions are where it comes FROM**, degrees true: `swell_from_deg`, `wind_from_deg`. Decision responses also give an 8-point compass (`swell_from: "NW"`).
- **Times are ISO 8601 with the spot's UTC offset**, plus the IANA timezone on the spot. Dates (`from`, `to`, `date`) are **spot-local** calendar days.
- **Every key is always present.** Unknown values are `null`, never omitted.
- **Tide heights** are feet above MLLW (`tide_datum: "MLLW"`).
- **Scores** are 0–100. Tiers: `excellent` ≥ 80, `great` ≥ 60, `good` ≥ 40, `fair` ≥ 20, else `no-go`. Low visibility caps the score — calm water can't buy back murky water.
- **Location** is one of: `spot` (an id), `region` (an id like `us-hi-oahu`), or `lat` + `lon`. Never more than one.

## Authentication

None required today. Facts are free and keyless; decisions are free during early access.

Keys aren't issued yet ([API keys](https://kaicast.com/developers/keys)). When they are, they'll be required for decisions once metering starts, sent like this — the header is already accepted and ignored, so you can wire it in now:

```http
Authorization: Bearer kc_live_xxxxxxxx_xxxxxxxxxxxxxxxx
```

or `X-KaiCast-Key: kc_live_…`. Test keys start with `kc_test_`. Keys never go in the URL.

## GET /api/v1/conditions

Facts for **one** spot, up to 7 days, in 3-hour periods.

| Parameter | Type | Notes |
|---|---|---|
| `spot` | string | Spot id, e.g. `electric-beach`. Or use `lat` + `lon`. |
| `lat`, `lon` | number | Matched to the nearest covered spot within 30 km; the response echoes `query.resolved_from` with the distance. |
| `from` | date | Spot-local `YYYY-MM-DD`. Default today. |
| `to` | date | Inclusive. Default `from` + 6. At most 7 days. |

```bash
curl "https://kaicast.com/api/v1/conditions?spot=electric-beach&from=2026-10-06&to=2026-10-06"
```

```json
{
  "schema": "kaicast.conditions.v1",
  "spot": {
    "id": "electric-beach", "name": "Electric Beach",
    "region": { "id": "us-hi-oahu", "name": "Oʻahu", "country": "US", "admin1": "Hawaii" },
    "lat": 21.355, "lon": -158.131, "timezone": "Pacific/Honolulu"
  },
  "query": { "spot": "electric-beach", "lat": null, "lon": null, "from": "2026-10-06", "to": "2026-10-06", "resolved_from": null },
  "range": { "from": "2026-10-06", "to": "2026-10-06", "available_from": "2026-10-05", "available_to": "2026-10-12" },
  "generated_at": "2026-10-05T10:00:00-10:00",
  "next_update_after": "2026-10-05T11:15:00-10:00",
  "data_quality": { "visibility_source": "satellite", "freshness": "live" },
  "tide_datum": "MLLW",
  "now": null,
  "periods": [
    {
      "kind": "forecast",
      "start": "2026-10-06T08:00:00-10:00", "end": "2026-10-06T11:00:00-10:00",
      "rating": "great", "score": 72,
      "visibility_ft": 45, "visibility_m": 13.7,
      "swell_height_ft": 2.1, "swell_height_m": 0.6, "swell_period_s": 12, "swell_from_deg": 310,
      "wind_speed_kt": 8, "wind_gust_kt": 12, "wind_from_deg": 65,
      "tide_state": "rising", "tide_height_ft": 0.9,
      "water_temp_c": 27.4,
      "confidence": 0.82, "visibility_source": "satellite",
      "why": "Clean water and light trades; small northwest swell wraps in but stays under the shelf."
    }
  ],
  "tide_events": [{ "type": "high", "time": "2026-10-06T13:42:00-10:00", "height_ft": 1.8 }],
  "sources": ["ndbc:51201", "noaa-tides:1612340", "pacioos-ww3", "openweather"],
  "links": { "page": "https://kaicast.com/conditions/electric-beach", "app": "https://kaicast.com/spot/electric-beach", "docs": "https://kaicast.com/openapi.json" },
  "attribution": "KaiCast (https://kaicast.com). …"
}
```

`now` is the current-hour nowcast when today is in range. The period containing *now* is kept; fully elapsed periods are dropped.

## GET /api/v1/spots

The spot directory — ids, names, regions, coordinates. No conditions.

| Parameter | Notes |
|---|---|
| `region` | Region id, e.g. `us-hi-maui` |
| `q` | Name search |
| `lat`, `lon` | Sort by distance; each spot gets `distance_km` |

```bash
curl "https://kaicast.com/api/v1/spots?region=us-hi-maui"
```

## GET /api/v1/coverage

Which regions are covered, whether each supports **decisions** (`decision_support: "full"`) or facts only (`"conditions_only"`), its spots and bounding box, and the activities with how directly each is modeled.

```bash
curl "https://kaicast.com/api/v1/coverage"
```

## GET /api/v1/recommendations

Ranked picks for one activity, in one area, on one spot-local day.

| Parameter | Type | Notes |
|---|---|---|
| `activity` | enum | **Required.** `snorkel`, `dive`, `freedive`, `spearfish`, `surf` |
| `region` / `spot` / `lat`+`lon` | | One location. Picks never span regions. |
| `radius_km` | number | Around `lat`/`lon`. Default 40, max 100. |
| `date` | date | Up to 6 days ahead. Default today — or tomorrow once today's daylight windows have passed, flagged by `date_rolled_forward: true` and a `message`. An explicit `date` is never moved. |
| `limit` | integer | Default 3, max 5. |

```bash
curl "https://kaicast.com/api/v1/recommendations?region=us-hi-oahu&activity=snorkel&date=2026-10-06&limit=2"
```

```json
{
  "schema": "kaicast.recommendation.v1",
  "status": "ok",
  "activity": { "id": "snorkel", "label": "Snorkeling", "model": "native", "note": "Scored by the KaiCast condition model, which is calibrated for snorkeling." },
  "date": "2026-10-06",
  "date_label": "Tomorrow",
  "coverage": { "status": "covered", "regions": ["us-hi-oahu"] },
  "picks": [
    {
      "rank": 1,
      "spot": { "id": "electric-beach", "name": "Electric Beach", "region": { "id": "us-hi-oahu", "name": "Oʻahu" }, "lat": 21.355, "lon": -158.131 },
      "distance_km": null,
      "date": "2026-10-06",
      "score": 72, "rating": "great",
      "best_window": { "start": "2026-10-06T08:00:00-10:00", "end": "2026-10-06T11:00:00-10:00", "start_local": "08:00", "end_local": "11:00", "score": 72, "rating": "great" },
      "why": {
        "summary": "Great: 45 ft visibility, 2.1 ft swell at 12s from the NW, 8 kt wind from the NE.",
        "factors": ["45 ft visibility", "2.1 ft swell at 12s from the NW", "8 kt wind from the NE", "rising tide"],
        "model_note": "Clean water and light trades; small northwest swell wraps in but stays under the shelf."
      },
      "metrics": { "visibility_ft": 45, "swell_height_ft": 2.1, "swell_period_s": 12, "swell_from": "NW", "wind_speed_kt": 8, "wind_from": "NE", "water_temp_f": 81, "tide_state": "rising" },
      "confidence": { "value": 0.74, "label": "high", "factors": { "model": 0.82, "visibility_source": 1, "freshness": 1, "lead_time": 0.95, "activity_fit": 1 } },
      "caveats": [],
      "card": { "…": "see Spot card below" }
    }
  ],
  "cards": ["…"],
  "card_markdown": "**Electric Beach** · Oʻahu — Tomorrow\nGreat (72/100) · best 08:00–11:00\n…",
  "disclaimer": "KaiCast forecasts are model estimates, not a safety guarantee. …",
  "pricing": { "layer": "decision", "plan": "free_early_access" }
}
```

**How picks are made.** Each spot's value for the day is its best *daylight* period (starting 06:00–17:59 local). Spots are ranked by that period's score; ties break on what matters most for the activity (calmer surface for snorkeling, clearer water for diving). Spots unsuited to the activity are excluded and listed in `considered.excluded` (e.g. deep boat dives aren't snorkel picks).

## GET /api/v1/best-day

Which day in a window is best at a destination.

| Parameter | Type | Notes |
|---|---|---|
| `activity` | enum | **Required.** |
| `spot` / `region` / `lat`+`lon` | | One destination. A region or radius picks the best spot *per day*. |
| `from`, `to` | date | Default today … +6. At most 7 days; a past `from` is clamped to today. |

```bash
curl "https://kaicast.com/api/v1/best-day?spot=hanauma-bay&activity=snorkel"
```

The response has `best` (a full pick, plus `why.versus_next` — e.g. *"9 points ahead of the next-best option (2026-10-05, 72/100)"*), `runner_up`, and `days[]` with every day's best score, spot and window.

## Statuses — honest answers, not errors

Decision endpoints return **200** with a `status` for anything that isn't a malformed request:

| `status` | Meaning | What to do |
|---|---|---|
| `ok` | Picks are present. | Show them, with caveats. |
| `no_data` | Covered, but no daylight forecast for that date (e.g. an explicit `date` of today, asked after dark). | Try another date. |
| `not_covered` | Outside KaiCast coverage. `coverage.nearest_covered` names the closest covered spot and distance. | Tell the user KaiCast doesn't cover it yet. |
| `conditions_only` | Region has facts but no calibrated visibility model. | Use `/api/v1/conditions`. |
| `activity_not_modeled` | E.g. `surf` — KaiCast doesn't model breaks. `facts_hint` points to the conditions endpoint. | Use swell/wind facts directly. |

## Activities

| Activity | Model | Meaning |
|---|---|---|
| `snorkel` | native | The condition score is calibrated for snorkeling. |
| `dive` | proxy | Same score; caveat added; confidence × 0.85. Conditions at depth aren't scored. |
| `freedive` | proxy | Same score; visibility breaks ties. |
| `spearfish` | proxy | Same score. **Spots in Hawaiʻi DAR areas closed to spearfishing are excluded** (`considered.excluded`, reason `closed_to_spearfishing`); bag limits and seasons are not modeled — a caveat says so. |
| `surf` | unsupported | Not ranked. Use the facts. |

## Confidence and caveats

`confidence.value` (0–1) is the product of its `factors` — the model's own visibility confidence, visibility source (satellite 1.0, estimated 0.8), input freshness, lead time (−5%/day), and activity fit (native 1.0, proxy 0.85). `label` is `high` ≥ 0.65, `medium` ≥ 0.4, else `low`.

`caveats[]` items have a stable `code` and a human `message`:
`visibility_estimated`, `stale_inputs`, `long_lead_time`, `marginal_conditions`, `location_snapped`, `proxy_activity_model`, `verify_fishing_rules`, `regulated_area`, `regulations_not_checked`.

## Spot card

Every decision pick carries `card`, and the response carries `cards[]` and `card_markdown`. Render it as-is so users see the same card they'd see in the KaiCast app:

```json
{
  "version": 1, "spot_id": "electric-beach", "spot_name": "Electric Beach", "region_name": "Oʻahu",
  "date": "2026-10-06", "date_label": "Tomorrow", "activity": "snorkel",
  "tier": "great", "tier_label": "Great", "tier_color": "#27D667", "score": 72,
  "headline": "Great: 45 ft visibility, 2.1 ft swell at 12s from the NW, 8 kt wind from the NE.",
  "best_window": { "start_local": "08:00", "end_local": "11:00" },
  "metrics": { "visibility": "45 ft", "swell": "2.1 ft @ 12s", "wind": "8 kt NE", "water_temp": "81°F" },
  "image_url": "https://kaicast.com/generateSpotShareImage?spot=electric-beach&date=2026-10-06",
  "links": {
    "share": "https://kaicast.com/s/electric-beach/2026-10-06?utm_source=agent&utm_medium=api&utm_campaign=spot_card_snorkel",
    "app": "kaicast://spot/electric-beach/2026-10-06",
    "forecast": "https://kaicast.com/spot/electric-beach?utm_source=agent&utm_medium=api&utm_campaign=spot_card_snorkel"
  },
  "cta": { "label": "Full forecast in KaiCast", "text": "Hour-by-hour windows, 7-day outlook and live hazard alerts in the KaiCast app.", "url": "https://kaicast.com/spot/electric-beach?…" },
  "attribution": "Conditions by KaiCast",
  "credits_text": "Weather data provided by OpenWeather · Buoy observations: NOAA NDBC · Data provided by PacIOOS (www.pacioos.org)"
}
```

`credits_text` is the licence-required upstream credit line — render it with the card. Each pick also carries `data_credits` (structured), and the response carries their union.

`image_url` is a 1200×630 PNG of the same card, for surfaces that show images. `tier_color` values: excellent `#09A1FB`, great `#27D667`, good `#FFD321`, fair `#FF9D25`, no-go `#F73726`.

## Errors

Malformed requests return [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) `application/problem+json` with a stable `code`:

```json
{
  "type": "https://kaicast.com/openapi.json#problem-unknown_activity",
  "title": "unknown activity",
  "status": 400,
  "detail": "Unknown activity \"kitesurf\". Use one of snorkel, dive, freedive, spearfish, surf.",
  "code": "unknown_activity"
}
```

| Status | Codes |
|---|---|
| 400 | `missing_location`, `ambiguous_location`, `invalid_coordinates`, `invalid_date`, `invalid_range`, `range_too_long`, `missing_activity`, `unknown_activity`, `invalid_limit`, `invalid_radius`, `one_spot_per_request` |
| 401 | `invalid_key`, `key_revoked`, `key_required` (decisions, once metering starts) |
| 402 | `free_allotment_exhausted` — body includes `upgrade_url` |
| 404 | `unknown_spot`, `no_spot_nearby` (conditions with lat/lon) |
| 422 | `outside_horizon` — dates beyond the 7-day forecast |
| 429 | `rate_limited` — honor `Retry-After` |
| 503 | `data_unavailable` / `unavailable` — retry after `Retry-After` |

## Limits

KaiCast serves answers **point by point** — one spot, one region, or one radius per call, for at most 7 days. There is no bulk export.

- **Facts:** generous per-caller hourly and daily caps, and a cap on how many *distinct* spots a caller can pull per hour and per day. Re-asking about a spot you already asked about today never counts against the distinct-spot cap. Requests from user-triggered AI fetchers (ChatGPT-User, Claude-User, Perplexity-User…) get higher caps because they share IPs.
- **Decisions:** at most 20 spots considered, 5 picks returned and 7 days per call; short bursts are throttled per IP.

Data refreshes hourly (`next_update_after`); polling faster than that returns the same answer.
