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, the OpenAPI 3.1 description of every operation, for agents and function calling. - Format: JSON. Send
Content-Type: application/jsonwith 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
messagestrings are in English; thestatusandfieldvalues 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.
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, withend_minutesat 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. timezoneis an IANA name such asEurope/Paris.titleand display names are at most 150 characters.- Activity categories are
dinner,drinks,activity,coffee,chill_somewhereandsomething_else.custom_suggestionis optional free text of at most 500 characters.
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.
- 200 with a failure
status:PUT /proposed-days,PUT /configuration,POST /activity-preferences,PATCH /participants/...,GET/DELETE /calendar,POST /calendar/connect,POST /calendar/freebusyandPUT /calendar/classificationsreturn{"status":"unauthorised_device"}for an invalid or missing key.PUT /proposed-days,PUT /configurationandPATCH /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 /workspacealways 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 /configurationandGET /activity-preferences,{"errors":[{"field":"request",…}]}. - 403:
{"status":"forbidden"}onPUT /availabilitycalled with the planner key (only a participant answers). - 404:
GET /overlap-resultswith an invalid key,{"errors":[{"field":"request",…}]};GET/PUT /availabilityandPOST /joinwith 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}whenbase_revisionorconfiguration_revisionis 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",…}]}onPOST /api/plans; and{"errors":[{"field":"display_name",…}]}onPOST /joinfor a name already taken. - 422: validation failure,
{"errors":[{"field":"…","message":"…"}]}with fields such astitle,planner_name,timezone,display_nameordays[0].range. The exception is{"status":"custom_suggestion_too_long"}onPOST /activity-preferences, which has noerrorsarray. - 429: too many requests,
{"errors":[{"field":"request",…}]}, with aRetry-Afterheader 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 onPOST /activity-preferencesanswers 500 with a{"status":"unauthorised_device"}body, with or without anIdempotency-Key. OnPUT /configuration,PATCH /participants/meandPATCH /participants/{participant_id}a storage failure answers HTTP 200{"status":"unauthorised_device"}when noIdempotency-Keyis 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):
{ "errors": [ { "field": "title", "message": "Enter a title of 150 characters or fewer." } ] }
Idempotency
- The
Idempotency-Keyheader (UUID v4) is required onPOST /api/plansand optional onPUT /api/plans/{id}/proposed-days,PUT /api/plans/{id}/availability,POST /api/plans/{id}/activity-preferences,PUT /api/plans/{id}/configurationandPATCH /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_keyfield), so keep the first response: it holds the only copies of the capabilities. POST /api/plans/{id}/joinignoresIdempotency-Keyand is not idempotent. If the response is lost, repeating the request can return 409 ondisplay_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) onPUT /proposed-daysandPUT /availability, storage failures, and refusals returned before the key is accepted (200unauthorised_deviceorforbidden, the 404 and 403 onPUT /availability, and the 409idempotency_conflictitself).
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 /healthis not limited. POST /api/plansalso 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 onplanwhen 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-referrerandX-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.