# Hangout API: developer reference

The Hangout HTTP API lets a program or an AI agent do what the web app does: create a private plan, share it, propose days, answer on behalf of a person who asked for it, and read the ranked time windows. It speaks JSON and needs no account.

- **Base URL**: `https://hangout-api-ulxjff27ga-od.a.run.app`
- **OpenAPI**: [`/openapi.json`](https://hangout-six-peach.vercel.app/openapi.json), the OpenAPI 3.1 description of every operation, for agents and function calling.
- **Format**: JSON. Send `Content-Type: application/json` with every request body.
- **Status**: invite-only private beta. The contract may still change.
- **Language**: this page is in English; the rest of the public pages are in French. The API itself is language-neutral. Error `message` strings are in English; the `status` and `field` values are stable identifiers.

## Authentication

There are no accounts, no API keys, and no OAuth tokens. Access is based on opaque capabilities, secrets returned exactly once and valid for a single plan. The server stores only a hash of each one and cannot return it again. Keep every capability and never write one to a log.

| Capability | Obtained from | Request header | Grants |
| --- | --- | --- | --- |
| Join link | `POST /api/plans` (`join_link`) | `X-Join-Capability` | read the plan title and join the plan |
| Planner key | `POST /api/plans` (`creator_edit_capability`) | `X-Participant-Capability` | everything the planner can do |
| Participant key | `POST /api/plans/{plan_id}/join` (`edit_capability`) | `X-Participant-Capability` | answer, and rename oneself |

The join link has the form `https://hangout-six-peach.vercel.app/p/{plan_id}#join={capability}`: the plan id is in the path and the capability is in the fragment. Send the fragment value as the `X-Join-Capability` header.

A missing or empty header counts as an invalid capability. Because the capabilities are bearer secrets, anyone holding a participant key can replace that participant's answer. Only act for a person who handed you their link or key, and only on their plan.

## Quickstart

This script needs `jq` to extract identifiers and capabilities from the JSON responses.

```bash
API=https://hangout-api-ulxjff27ga-od.a.run.app
# A future day, portable across macOS (BSD date) and Linux (GNU date); a proposed
# day must fall between today and one year ahead.
DAY=$(date -d '+7 days' +%F 2>/dev/null || date -v+7d +%F)

# 1. Create a plan (Idempotency-Key is required, UUID v4)
CREATE_RESPONSE=$(curl -sS -X POST "$API/api/plans" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen | tr 'A-Z' 'a-z')" \
  -d '{"title":"Back-to-school dinner","planner_name":"Alex","timezone":"Europe/Paris"}')
# => 201 {"plan":{"id":"…","title":"…","timezone":"…","planner_name":"…"},"join_link":"…#join=…","creator_edit_capability":"…"}

PLAN_ID=$(echo "$CREATE_RESPONSE" | jq -r '.plan.id')
CREATOR=$(echo "$CREATE_RESPONSE" | jq -r '.creator_edit_capability')
JOIN=$(echo "$CREATE_RESPONSE" | jq -r '.join_link' | sed 's/.*#join=//')

# 2. Propose days (minutes since midnight, multiples of 30)
curl -sS -X PUT "$API/api/plans/$PLAN_ID/proposed-days" \
  -H 'Content-Type: application/json' -H "X-Participant-Capability: $CREATOR" \
  -d "{\"days\":[{\"date\":\"$DAY\",\"start_minutes\":1140,\"end_minutes\":1380}]}"

# 3. Join with the join capability, then answer
JOIN_RESPONSE=$(curl -sS -X POST "$API/api/plans/$PLAN_ID/join" \
  -H 'Content-Type: application/json' -H "X-Join-Capability: $JOIN" \
  -d '{"display_name":"Sam"}')
# => 201 {"participant_id":"…","display_name":"Sam","edit_capability":"…"}

EDIT=$(echo "$JOIN_RESPONSE" | jq -r '.edit_capability')

curl -sS -X PUT "$API/api/plans/$PLAN_ID/availability" \
  -H 'Content-Type: application/json' -H "X-Participant-Capability: $EDIT" \
  -d "{\"days\":[{\"date\":\"$DAY\",\"start_minutes\":1170,\"end_minutes\":1380}]}"

# 4. Read the ranked time windows
curl -sS "$API/api/plans/$PLAN_ID/overlap-results" -H "X-Participant-Capability: $CREATOR"
```

## Endpoints

"Key" means either the planner key or a participant key, sent in `X-Participant-Capability`.

| Method and path | Capability | Purpose |
| --- | --- | --- |
| `GET /health` | none | liveness check, returns the text `ok` |
| `POST /api/plans` | none | create a plan; body `{title, planner_name, timezone}` |
| `GET /api/plans/{id}/workspace` | join link or key | plan state: `join_required`, `ready`, `invalid_link` or `unauthorised_device` |
| `POST /api/plans/{id}/join` | join link | join the plan; body `{display_name}` |
| `GET /api/plans/{id}/availability` | key | proposed days and the caller's own answer |
| `PUT /api/plans/{id}/proposed-days` | planner key | replace the whole proposal; body `{days: [...]}` |
| `PUT /api/plans/{id}/availability` | participant key | replace the whole answer; an empty list withdraws it |
| `GET /api/plans/{id}/overlap-results` | key | time windows ranked by number of people free |
| `GET /api/plans/{id}/configuration` | key | offered activity categories and the configuration revision |
| `PUT /api/plans/{id}/configuration` | planner key | change the categories; body `{allowed_categories, base_revision}` |
| `GET /api/plans/{id}/activity-preferences` | key | aggregated and individual activity preferences |
| `POST /api/plans/{id}/activity-preferences` | key | save the caller's preference; body `{categories, no_preference, custom_suggestion, configuration_revision}` |
| `PATCH /api/plans/{id}/participants/me` | key | rename the caller; body `{display_name, base_revision}` |
| `PATCH /api/plans/{id}/participants/{participant_id}` | planner key | rename a participant; same body |
| `GET /api/plans/{id}/calendar`, `DELETE /api/plans/{id}/calendar` | key | Google Calendar connection status, or disconnect |
| `POST /api/plans/{id}/calendar/connect` | key | returns a Google consent URL (a human browser is required) |

The routes `calendar/freebusy`, `calendar/classifications`, `calendar/complete` and `/auth/google/callback` serve the web app's Google Calendar flow and are not meant for agents.

### Data formats

- A day is `{"date":"YYYY-MM-DD","start_minutes":n,"end_minutes":n}`: minutes since midnight in the plan's timezone, multiples of 30, with `end_minutes` at most 1440. One date may carry several ranges; overlapping or touching ranges are merged. A newly proposed day must fall between today and one year ahead.
- `timezone` is an IANA name such as `Europe/Paris`. `title` and display names are at most 150 characters.
- Activity categories are `dinner`, `drinks`, `activity`, `coffee`, `chill_somewhere` and `something_else`. `custom_suggestion` is optional free text of at most 500 characters.

## Example requests

Each example shows the request and the response body.

### Create a plan

```bash
curl -sS -X POST "$API/api/plans" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: 3f8a1c52-7d4e-4b19-9a60-2c5e8f1d7b03' \
  -d '{"title":"Back-to-school dinner","planner_name":"Alex","timezone":"Europe/Paris"}'
```

```json
{
  "plan": { "id": "6b1f4a0e-…", "title": "Back-to-school dinner", "timezone": "Europe/Paris", "planner_name": "Alex" },
  "join_link": "https://hangout-six-peach.vercel.app/p/6b1f4a0e-…#join=<join-capability>",
  "creator_edit_capability": "<planner-key>"
}
```

HTTP status 201. This is the only response that carries `join_link` and `creator_edit_capability`.

### Read the plan state

```bash
curl -sS "$API/api/plans/$PLAN_ID/workspace" -H "X-Participant-Capability: $EDIT"
```

```json
{
  "status": "ready",
  "plan": { "id": "6b1f4a0e-…", "title": "Back-to-school dinner", "timezone": "Europe/Paris" },
  "participant": { "id": "…", "display_name": "Sam", "display_name_revision": 0 },
  "role": "participant",
  "participants": [ { "id": "…", "display_name": "Sam", "display_name_revision": 0 } ],
  "configuration": { "revision": 0, "allowed_categories": ["dinner", "drinks"] }
}
```

`role` is `principal_planner` or `participant`. With only `X-Join-Capability` the response is `{"status":"join_required","plan":{…}}`. The response is always HTTP 200; failure is a `status` of `invalid_link` or `unauthorised_device`, with no plan data.

### Propose days (planner)

```bash
curl -sS -X PUT "$API/api/plans/$PLAN_ID/proposed-days" \
  -H 'Content-Type: application/json' -H "X-Participant-Capability: $CREATOR" \
  -d '{"days":[{"date":"2026-11-14","start_minutes":1140,"end_minutes":1380}]}'
```

```json
{
  "status": "ok",
  "revision": 3,
  "days": [ { "date": "2026-11-14", "start_minutes": 1140, "end_minutes": 1380, "answered": false, "day_answered": false } ]
}
```

`answered` is true once another participant's answer strictly overlaps that range; `day_answered` is true once any other participant has an answer on that date. Removing or shrinking answered time is refused with 409 (see Errors).

### Answer availability (participant)

```bash
curl -sS -X PUT "$API/api/plans/$PLAN_ID/availability" \
  -H 'Content-Type: application/json' -H "X-Participant-Capability: $EDIT" \
  -d '{"days":[{"date":"2026-11-14","start_minutes":1170,"end_minutes":1380}]}'
```

```json
{ "revision": 4, "recalculation": "pending" }
```

The planner key is refused here with 403, because the planner's availability is the proposal itself. Results are recalculated in the background.

### Read ranked results

```bash
curl -sS "$API/api/plans/$PLAN_ID/overlap-results" -H "X-Participant-Capability: $EDIT"
```

```json
{
  "current_revision": 4,
  "calculated_revision": 4,
  "participant_count": 2,
  "participants": [
    { "id": "00000000-0000-4000-8000-000000000001", "display_name": "Alex", "answered": true },
    { "id": "00000000-0000-4000-8000-000000000002", "display_name": "Sam", "answered": true },
    { "id": "00000000-0000-4000-8000-000000000003", "display_name": "Lou", "answered": false }
  ],
  "candidates": [
    {
      "date": "2026-11-14", "start_minutes": 1170, "end_minutes": 1380, "free_count": 2,
      "free_participant_ids": ["00000000-0000-4000-8000-000000000001", "00000000-0000-4000-8000-000000000002"]
    }
  ]
}
```

Candidates are ordered by `free_count` descending, then windows inside the planner's proposed ranges before windows outside them (answers beyond the proposal still count), then longer windows, then earlier date, then earlier start. A window never straddles a proposed range edge: it is split there. Windows shorter than 60 minutes are left out, except a short window with a higher `free_count` than every window of 60 minutes or more, and except when no window reaches 60 minutes, in which case the short ones are kept. Each candidate lists `free_participant_ids`, the participants free for the whole window, and `participants` maps ids to display names and says who has answered. The planner is never counted or listed. The response never contains a person's ranges as such, but it returns every window, so anyone holding a key can combine the windows and their `free_participant_ids` to rebuild each non-planner participant's availability on the proposed days in 30-minute steps. Treat that availability as visible to everyone in the plan. `participant_count` counts the listed participants with `answered: true`. The candidates are current only when `calculated_revision` equals `current_revision`; `calculated_revision` is `null` before the first calculation.

### Set an activity preference

```bash
curl -sS -X POST "$API/api/plans/$PLAN_ID/activity-preferences" \
  -H 'Content-Type: application/json' -H "X-Participant-Capability: $EDIT" \
  -d '{"categories":["dinner"],"no_preference":false,"custom_suggestion":null,"configuration_revision":0}'
```

```json
{ "status": "ok", "participant_id": "…", "categories": ["dinner"], "no_preference": false, "custom_suggestion": null }
```

### Rename a participant

```bash
curl -sS -X PATCH "$API/api/plans/$PLAN_ID/participants/me" \
  -H 'Content-Type: application/json' -H "X-Participant-Capability: $EDIT" \
  -d '{"display_name":"Samuel","base_revision":0}'
```

```json
{ "status": "ok", "participant_id": "…", "display_name": "Samuel", "revision": 1 }
```

`base_revision` is the `display_name_revision` last read from `workspace`.

## Errors

The machine-readable description of every operation, request, and response body is the [OpenAPI document](https://hangout-six-peach.vercel.app/openapi.json).

Check the HTTP status first, then the `status` field of the body when there is one. Several routes answer HTTP 200 even when the request failed (invalid key, refusal), so a 200 is not proof of success.

- **200 with a failure `status`**: `PUT /proposed-days`, `PUT /configuration`, `POST /activity-preferences`, `PATCH /participants/...`, `GET`/`DELETE /calendar`, `POST /calendar/connect`, `POST /calendar/freebusy` and `PUT /calendar/classifications` return `{"status":"unauthorised_device"}` for an invalid or missing key. `PUT /proposed-days`, `PUT /configuration` and `PATCH /participants/{participant_id}` return `{"status":"forbidden"}` when the caller is not the planner. A rename to a name already taken returns `{"status":"duplicate_name"}`, and an empty or overlong name returns `{"status":"invalid_name"}`. `GET /workspace` always returns 200 with `{"status":"ready"|"join_required"|"invalid_link"|"unauthorised_device"}`.
- **400**: `{"status":"invalid_categories"}` (`POST /activity-preferences`) or `{"status":"duplicate_categories"}` (`PUT /configuration`).
- **401**: invalid or missing key on `GET /configuration` and `GET /activity-preferences`, `{"errors":[{"field":"request",…}]}`.
- **403**: `{"status":"forbidden"}` on `PUT /availability` called with the planner key (only a participant answers).
- **404**: `GET /overlap-results` with an invalid key, `{"errors":[{"field":"request",…}]}`; `GET`/`PUT /availability` and `POST /join` with an invalid key or join link, `{"errors":[{"field":"participant_capability"|"join_capability","message":"…"}]}`.
- **409**: `{"status":"locked_days","dates":[…]}` when a proposal would remove or shrink time that already has answers (nothing is written); `{"status":"stale_edit","current_revision":n}` when `base_revision` or `configuration_revision` is out of date; `{"status":"idempotency_conflict"}` for an idempotency key reused with a different body, or a key that is not a UUID v4; `{"errors":[{"field":"idempotency_key",…}]}` on `POST /api/plans`; and `{"errors":[{"field":"display_name",…}]}` on `POST /join` for a name already taken.
- **422**: validation failure, `{"errors":[{"field":"…","message":"…"}]}` with fields such as `title`, `planner_name`, `timezone`, `display_name` or `days[0].range`. The exception is `{"status":"custom_suggestion_too_long"}` on `POST /activity-preferences`, which has no `errors` array.
- **429**: too many requests, `{"errors":[{"field":"request",…}]}`, with a `Retry-After` header in seconds.
- **503**: temporary overload or a request over 30 seconds; retry later.
- **500**: `{"errors":[{"field":"request","message":"…"}]}`; an unexpected server error. One exception: a storage failure on `POST /activity-preferences` answers 500 with a `{"status":"unauthorised_device"}` body, with or without an `Idempotency-Key`. On `PUT /configuration`, `PATCH /participants/me` and `PATCH /participants/{participant_id}` a storage failure answers HTTP 200 `{"status":"unauthorised_device"}` when no `Idempotency-Key` is sent, so that status may also mean a transient server error and one retry is reasonable. With a key, any storage failure (beginning the key, the write itself, or recording the response) answers 500 with the same `{"status":"unauthorised_device"}` body, not the errors envelope, and nothing is recorded.
- **Any other error**: a malformed request (400), unknown route (404), wrong method (405), oversized body (413) or wrong content type (415) answers `{"errors":[{"field":"request","message":"…"}]}`.

Example validation error (HTTP 422):

```json
{ "errors": [ { "field": "title", "message": "Enter a title of 150 characters or fewer." } ] }
```

## Idempotency

- The `Idempotency-Key` header (UUID v4) is required on `POST /api/plans` and optional on `PUT /api/plans/{id}/proposed-days`, `PUT /api/plans/{id}/availability`, `POST /api/plans/{id}/activity-preferences`, `PUT /api/plans/{id}/configuration` and `PATCH /api/plans/{id}/participants/...`. On those writes, replaying the same key with the same body returns the recorded response; reusing a key with a different body returns 409.
- Creating a plan is never replayed. A repeat of a completed creation returns 409 (`idempotency_key` field), so keep the first response: it holds the only copies of the capabilities.
- `POST /api/plans/{id}/join` ignores `Idempotency-Key` and is not idempotent. If the response is lost, repeating the request can return 409 on `display_name` (name already taken) instead of the participant already created.
- Every response after the key is accepted is recorded under it and replayed on a retry, including refusals such as 400, 409 (`locked_days`, `stale_edit`) and the typed 422 bodies. After correcting a request, use a fresh key. Not recorded, so retryable with the same key: the invalid-day 422 (`days[i].*` fields) on `PUT /proposed-days` and `PUT /availability`, storage failures, and refusals returned before the key is accepted (200 `unauthorised_device` or `forbidden`, the 404 and 403 on `PUT /availability`, and the 409 `idempotency_conflict` itself).

## Rate limits

- Per client: 60 requests per minute per capability, 300 requests per minute per IP address, and 8 simultaneous requests per IP address. `GET /health` is not limited.
- `POST /api/plans` also has its own budget: 10 plans per hour per IP address.
- A rejected request gets 429 with `Retry-After`: 1 second for the simultaneous-request cap, 60 seconds for the per-minute budgets, 3600 seconds for the plan-creation budget.
- Request bodies are limited to 64 KB (413 past it). A proposal or an answer holds at most 500 ranges (422 on `days[500].range`), and a plan holds at most 50 participants (409 on `plan` when joining a full plan).
- A global concurrency limit sheds load with 503 when the service is saturated.
- CORS only allows the Hangout web app origin; non-browser clients (servers, agents) are not affected.
- Private responses carry `Cache-Control: no-store`, `Referrer-Policy: no-referrer` and `X-Robots-Tag: noindex, nofollow`.

## Guidance for agents

- Act on someone's behalf only if they gave you their link or key, and only on their plan.
- Never share an edit capability: it gives control of its owner's answer. To invite someone, pass on the join link.
- Show who is free per slot as the API does; do not publish a reconstruction of a person's availability outside the plan.
- Connecting Google Calendar needs a person to consent in a browser; entering availability by hand always works.
