Hangout

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.

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.

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

Example requests

Each example shows the request and the response body.

Create a plan

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"}'
{
  "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

curl -sS "$API/api/plans/$PLAN_ID/workspace" -H "X-Participant-Capability: $EDIT"
{
  "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)

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}]}'
{
  "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)

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}]}'
{ "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

curl -sS "$API/api/plans/$PLAN_ID/overlap-results" -H "X-Participant-Capability: $EDIT"
{
  "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

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}'
{ "status": "ok", "participant_id": "…", "categories": ["dinner"], "no_preference": false, "custom_suggestion": null }

Rename a participant

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}'
{ "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.

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.

Example validation error (HTTP 422):

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

Idempotency

Rate limits

Guidance for agents