{"openapi":"3.0.3","info":{"title":"WonderCal API","version":"1.0.0","description":"WonderCal is a meeting scheduling API that helps users find optimal meeting times based on calendar availability. Authenticate with a Bearer token obtained via the token exchange endpoint."},"servers":[{"url":"/","description":"Same origin as this document"}],"security":[{"bearerAuth":[]}],"paths":{"/api/auth/token":{"post":{"summary":"Exchange an API key or refresh token for a Bearer token","description":"Authenticates using either an API key or a refresh token and returns a short-lived Bearer token plus a new refresh token. This endpoint is exempt from Bearer authentication and global rate limiting. It has its own IP-based rate limit.","security":[],"tags":["Auth"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenExchangeRequest"}}}},"responses":{"200":{"description":"Token exchange succeeded","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/TokenExchangeResponse"}}},"example":{"data":{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","refreshToken":"v02.local.abc123..."}}}}},"400":{"description":"Missing or invalid request body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"413":{"description":"Request body exceeds 1 MB limit","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Rate limit exceeded. Retry-After header indicates seconds until the next request is allowed.","headers":{"Retry-After":{"description":"Seconds to wait before retrying","schema":{"type":"integer","example":60}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/keys":{"get":{"summary":"List API keys","description":"Returns metadata for all API keys belonging to the authenticated user. Key hashes are never exposed.","tags":["API Keys"],"responses":{"200":{"description":"List of API key metadata","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ApiKeyListItem"}}}},"example":{"data":[{"id":"k5gmdr9txcq6dqaasj5q9p0d5h7753xh","name":"My Integration","createdAt":"2026-01-15T10:30:00.000Z","lastUsedAt":"2026-02-20T14:22:00.000Z"}]}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}},"post":{"summary":"Create an API key","description":"Generates a new API key. The plaintext key is returned once and cannot be retrieved later. Maximum 25 keys per user.","tags":["API Keys"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyRequest"}}}},"responses":{"201":{"description":"API key created successfully","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ApiKeyCreated"}}},"example":{"data":{"id":"k5gmdr9txcq6dqaasj5q9p0d5h7753xh","key":"wcal_aBcDeFgHiJkLmNoPqRsTuVwXyZ012345678901234","name":"My Integration","createdAt":"2026-01-15T10:30:00.000Z"}}}}},"400":{"description":"Validation error (empty name, name too long, key limit reached)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"413":{"description":"Request body exceeds 1 MB limit (PAYLOAD_TOO_LARGE)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/keys/{id}":{"delete":{"summary":"Delete an API key","description":"Deletes an API key owned by the authenticated user. Returns 404 if the key does not exist or belongs to another user.","tags":["API Keys"],"parameters":[{"name":"id","in":"path","required":true,"description":"API key ID","schema":{"type":"string"}}],"responses":{"200":{"description":"Key deleted successfully","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"deleted":{"type":"boolean","example":true}}}}}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"API key not found or not owned by user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/webhooks":{"get":{"summary":"List webhook endpoints","description":"Returns all webhook endpoints registered by the authenticated user. Signing secrets are never included in list responses.","tags":["Webhooks"],"responses":{"200":{"description":"List of webhook endpoints","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEndpointListItem"}}}},"example":{"data":[{"id":"j5gmdr9txcq6dqaasj5q9p0d5h7753yx","url":"https://api.example.com/webhooks/wondercal","subscribedEvents":["meeting.confirmed","meeting.canceled"],"status":"active","createdAt":"2026-01-15T10:30:00.000Z"}]}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Not authorized (e.g., not the organizer or plan lacks webhooks)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}},"post":{"summary":"Register a webhook endpoint","description":"Registers a new HTTPS endpoint to receive scheduling events. Requires the Business plan or above. The signing secret is returned once and cannot be retrieved again — store it securely. URLs are validated for HTTPS scheme and checked against private/link-local/loopback/reserved address ranges before the endpoint is created.","tags":["Webhooks"],"parameters":[{"$ref":"#/components/parameters/IdempotencyKeyHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWebhookEndpointRequest"}}}},"responses":{"201":{"description":"Webhook endpoint created","headers":{"Idempotency-Replayed":{"$ref":"#/components/headers/IdempotencyReplayed"}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WebhookEndpointCreated"}}},"example":{"data":{"endpointId":"j5gmdr9txcq6dqaasj5q9p0d5h7753yx","url":"https://api.example.com/webhooks/wondercal","subscribedEvents":["meeting.confirmed"],"secretKey":"whsec_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789...","status":"active","createdAt":"2026-01-15T10:30:00.000Z"}}}}},"400":{"description":"Validation error: non-HTTPS URL, empty/unknown event types, blocked address range, or plan-limit cap reached","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Not authorized (plan gate — unreachable under current policy; all plans include webhooks as of DW-956)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"$ref":"#/components/responses/IdempotencyConflict"},"413":{"description":"Request body exceeds 1 MB limit","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/webhooks/{id}":{"delete":{"summary":"Delete a webhook endpoint","description":"Deletes a webhook endpoint owned by the authenticated user. In-flight retries for already-fired events are abandoned.","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"description":"Webhook endpoint ID","schema":{"type":"string"}}],"responses":{"200":{"description":"Endpoint deleted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"deleted":{"type":"boolean","example":true}}}}}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Endpoint not found or not owned by user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/webhooks/{id}/rotate-secret":{"post":{"summary":"Rotate a webhook endpoint's signing secret","description":"Generates a new signing secret, returned once. Every delivery attempt made after rotation — including retries of events that were first attempted before rotation — is signed with both the new secret and the previous secret for WEBHOOK_SECRET_ROTATION_GRACE_MS (24 hours) after rotation. The X-WonderCal-Signature header carries one v1= entry per active secret during this window (for example: X-WonderCal-Signature: v1=<hex1>,v1=<hex2>) — verify against any entry that matches; you do not need to update your verification secret at the exact moment of rotation. After the grace window elapses, only the new secret is used to sign deliveries and the header reverts to a single v1= entry.","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"description":"Webhook endpoint ID","schema":{"type":"string"}}],"responses":{"200":{"description":"Secret rotated","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WebhookSecretRotated"}}},"example":{"data":{"secretKey":"whsec_zYxWvUtSrQpOnMlKjIhGfEdCbA9876543210..."}}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Endpoint not found or not owned by user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/webhooks/{id}/test-ping":{"post":{"summary":"Send a test ping to a webhook endpoint","description":"Delivers a synthetic 'ping' event to confirm the endpoint and signature verification work. Implemented in AIA-13 — this route currently returns 501 NOT_IMPLEMENTED.","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"description":"Webhook endpoint ID","schema":{"type":"string"}}],"responses":{"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Endpoint not found or not owned by user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"501":{"description":"Not yet implemented (tracked as AIA-13)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/webhooks/{id}/deliveries":{"get":{"summary":"List delivery attempts for a webhook endpoint","description":"Returns a paginated log of delivery attempts for the given endpoint, newest first: event type, event ID, timestamp, HTTP status (or timeout/connection-error indicator), attempt number, and state.","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"description":"Webhook endpoint ID","schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"description":"Max results per page (default 50, max 100)","schema":{"type":"integer"}},{"name":"cursor","in":"query","required":false,"description":"Opaque pagination cursor from a previous response's nextCursor","schema":{"type":"string"}}],"responses":{"200":{"description":"Delivery log page","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WebhookDeliveryListResponse"}}}}}},"400":{"description":"Invalid limit parameter (must be an integer between 1 and 100)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Endpoint not found or not owned by user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/webhooks/{id}/deliveries/{deliveryId}/resend":{"post":{"summary":"Manually resend a dead-lettered delivery","description":"Immediately re-attempts delivery of a dead_letter delivery, bypassing the normal exponential-backoff schedule. Delivers the ORIGINAL historical payload — if the underlying meeting has since been deleted, the resend still sends the payload as it existed at the time the event originally fired. Only dead_letter deliveries are eligible.","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"description":"Webhook endpoint ID","schema":{"type":"string"}},{"name":"deliveryId","in":"path","required":true,"description":"Webhook delivery ID","schema":{"type":"string"}}],"responses":{"200":{"description":"Resend attempted","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"resent":{"type":"boolean","example":true},"state":{"type":"string","enum":["delivered","retrying","dead_letter"]}}}}}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Endpoint or delivery not found or not owned by user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Delivery exists and is owned by caller but is not in the dead_letter state","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/meetings":{"get":{"summary":"List meetings","description":"Returns all meetings the authenticated user is a participant in.","tags":["Meetings"],"responses":{"200":{"description":"List of meetings","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Meeting"}}}}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}},"post":{"summary":"Create a meeting","description":"Creates a new meeting. The authenticated user is automatically added as a participant and organizer.","tags":["Meetings"],"parameters":[{"$ref":"#/components/parameters/IdempotencyKeyHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateMeetingRequest"}}}},"responses":{"201":{"description":"Meeting created successfully","headers":{"Idempotency-Replayed":{"$ref":"#/components/headers/IdempotencyReplayed"}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Meeting"}}}}}},"400":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"$ref":"#/components/responses/IdempotencyConflict"},"413":{"description":"Request body exceeds 1 MB limit","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/meetings/{id}":{"get":{"summary":"Get meeting details","description":"Returns detailed information about a meeting including participants. The authenticated user must be a participant.","tags":["Meetings"],"parameters":[{"name":"id","in":"path","required":true,"description":"Meeting ID","schema":{"type":"string"}}],"responses":{"200":{"description":"Meeting details","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MeetingDetail"}}}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"User is not a participant of this meeting","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Meeting not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}},"patch":{"summary":"Update a meeting","description":"Partially updates meeting fields. Only the meeting organizer can update the meeting.","tags":["Meetings"],"parameters":[{"name":"id","in":"path","required":true,"description":"Meeting ID","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMeetingRequest"}}}},"responses":{"200":{"description":"Meeting updated successfully","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Meeting"}}}}}},"400":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"User is not the organizer","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Meeting not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"413":{"description":"Request body exceeds 1 MB limit","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/meetings/{id}/confirm":{"post":{"summary":"Confirm a meeting","description":"Confirms a meeting at a specific time. Creates Google Calendar events for participants and sends email notifications. Only the organizer can confirm.","tags":["Meetings"],"parameters":[{"name":"id","in":"path","required":true,"description":"Meeting ID","schema":{"type":"string"}},{"$ref":"#/components/parameters/IdempotencyKeyHeaderPathScoped"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConfirmMeetingRequest"}}}},"responses":{"200":{"description":"Meeting confirmed","headers":{"Idempotency-Replayed":{"$ref":"#/components/headers/IdempotencyReplayed"}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ConfirmResponse"}}}}}},"400":{"description":"Validation error (missing time, already confirmed)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"User is not the organizer","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Meeting not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"$ref":"#/components/responses/IdempotencyConflict"},"413":{"description":"Request body exceeds 1 MB limit","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/meetings/{id}/unconfirm":{"post":{"summary":"Unconfirm a meeting","description":"Reverts a confirmed meeting back to pending status. Only the organizer can unconfirm.","tags":["Meetings"],"parameters":[{"name":"id","in":"path","required":true,"description":"Meeting ID","schema":{"type":"string"}}],"responses":{"200":{"description":"Meeting unconfirmed","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"unconfirmed":{"type":"boolean","example":true}}}}}}}},"400":{"description":"Meeting is not confirmed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"User is not the organizer","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Meeting not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/meetings/{id}/deadlines":{"post":{"summary":"Set meeting deadlines","description":"Set or clear a meeting's deadlines so it happens by a certain date. Only the organizer can set deadlines, and the meeting must be unconfirmed. All fields are optional: omit a field to leave it unchanged, or pass null for meetBy/respondBy to clear that deadline. To guarantee a meeting is scheduled by a date, set meetBy plus a respondBy (on or before meetBy) with onResponseDeadline='auto_schedule'. onResponseDeadline='auto_schedule' REQUIRES meetBy to be set (in this request or already saved on the meeting) — clearing meetBy with respondBy/onResponseDeadline='auto_schedule' still in effect is rejected.","tags":["Meetings"],"parameters":[{"name":"id","in":"path","required":true,"description":"Meeting ID","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"meetBy":{"type":"string","nullable":true,"format":"date-time","description":"Latest date/time the meeting may be scheduled for (ISO 8601). null clears it.","example":"2026-07-31T17:00:00.000Z"},"respondBy":{"type":"string","nullable":true,"format":"date-time","description":"Response deadline (ISO 8601), on or before meetBy. null clears it.","example":"2026-07-30T17:00:00.000Z"},"onResponseDeadline":{"type":"string","nullable":true,"enum":["auto_schedule","notify_organizer"],"description":"What happens when respondBy passes. Required when setting respondBy; defaults to notify_organizer. 'auto_schedule' requires meetBy to be set (in this request or already saved on the meeting)."}}}}}},"responses":{"200":{"description":"Deadlines updated","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"meetBy":{"type":"string","nullable":true,"format":"date-time"},"respondBy":{"type":"string","nullable":true,"format":"date-time"},"onResponseDeadline":{"type":"string","nullable":true,"enum":["auto_schedule","notify_organizer"]}}}}}}}},"400":{"description":"Invalid deadline values","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"User is not the organizer","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Meeting not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Meeting is confirmed and cannot be modified","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/meetings/{id}/join":{"post":{"summary":"Join a meeting","description":"Adds the authenticated user as a participant to the meeting.","tags":["Meetings"],"parameters":[{"name":"id","in":"path","required":true,"description":"Meeting ID","schema":{"type":"string"}}],"responses":{"200":{"description":"Successfully joined","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"joined":{"type":"boolean","example":true}}}}}}}},"400":{"description":"Already a participant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Meeting not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/meetings/{id}/participants":{"post":{"summary":"Add a participant by email","description":"Adds a user to the meeting by their email address. Only the organizer can add participants.","tags":["Meetings"],"parameters":[{"name":"id","in":"path","required":true,"description":"Meeting ID","schema":{"type":"string"}},{"$ref":"#/components/parameters/IdempotencyKeyHeaderPathScoped"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email"],"properties":{"email":{"type":"string","format":"email","description":"Email address of the participant to add","example":"user@example.com"}}}}}},"responses":{"200":{"description":"Already a participant, or unknown email (invitation sent)","headers":{"Idempotency-Replayed":{"$ref":"#/components/headers/IdempotencyReplayed"}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"added":{"type":"boolean","description":"Whether the user was added as a participant","example":false},"alreadyParticipant":{"type":"boolean","description":"True when the user is already a participant or the owner"},"invited":{"type":"boolean","description":"True when an invitation email was sent to an unknown email address"},"emailSent":{"type":"boolean","description":"Whether an invitation email was successfully sent"},"userId":{"type":"string","description":"User ID of the existing participant (when alreadyParticipant is true)"}}}}}}}},"201":{"description":"Participant added successfully","headers":{"Idempotency-Replayed":{"$ref":"#/components/headers/IdempotencyReplayed"}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"added":{"type":"boolean","example":true},"userId":{"type":"string","description":"User ID of the added participant"},"emailSent":{"type":"boolean","description":"Whether an invitation email was successfully sent"}}}}}}}},"400":{"description":"Validation error (invalid email, already a participant)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"User is not the organizer","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Meeting not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"$ref":"#/components/responses/IdempotencyConflict"},"413":{"description":"Request body exceeds 1 MB limit","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}},"delete":{"summary":"Leave a meeting","description":"Removes the authenticated user from the meeting. The organizer cannot leave their own meeting.","tags":["Meetings"],"parameters":[{"name":"id","in":"path","required":true,"description":"Meeting ID","schema":{"type":"string"}}],"responses":{"200":{"description":"Successfully left the meeting","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"left":{"type":"boolean","example":true}}}}}}}},"400":{"description":"Organizer cannot leave their own meeting","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Meeting not found or user is not a participant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/meetings/{id}/overlap":{"get":{"summary":"Find overlapping availability","description":"Computes time slots where all participants are available. Returns overlap windows for scheduling.","tags":["Meetings"],"parameters":[{"name":"id","in":"path","required":true,"description":"Meeting ID","schema":{"type":"string"}}],"responses":{"200":{"description":"Overlap windows","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/OverlapSlot"}}}}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"User is not a participant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Meeting not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/meetings/{id}/availability":{"get":{"summary":"Get availability for a meeting","description":"Returns availability slots for the authenticated user for the specified meeting.","tags":["Meetings"],"parameters":[{"name":"id","in":"path","required":true,"description":"Meeting ID","schema":{"type":"string"}}],"responses":{"200":{"description":"Availability slots","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AvailabilitySlot"}}}}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"User is not a participant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Meeting not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}},"put":{"summary":"Save availability for a meeting","description":"Saves or replaces the authenticated user's availability slots for the specified meeting.","tags":["Meetings"],"parameters":[{"name":"id","in":"path","required":true,"description":"Meeting ID","schema":{"type":"string"}},{"$ref":"#/components/parameters/IdempotencyKeyHeaderPathScoped"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["slots"],"properties":{"slots":{"type":"array","items":{"$ref":"#/components/schemas/AvailabilitySlot"},"description":"Array of availability time slots"}}}}}},"responses":{"200":{"description":"Availability saved","headers":{"Idempotency-Replayed":{"$ref":"#/components/headers/IdempotencyReplayed"}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"saved":{"type":"boolean","example":true}}}}}}}},"400":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"User is not a participant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Meeting not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"$ref":"#/components/responses/IdempotencyConflict"},"413":{"description":"Request body exceeds 1 MB limit","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/meetings/{id}/relay-availability":{"post":{"summary":"Relay availability for an invited guest (organizer path)","description":"Allows the meeting organizer to submit availability slots on behalf of an invited guest who is communicating out-of-band (e.g. via a conversational agent). The slots are recorded against the guest's existing invite/participant record — relaying does not implicitly invite an email that has not already been added to the meeting. Replaces the guest's prior relayed-or-submitted slots (last-write-wins). If the guest has a connected calendar, their real-time free/busy still takes precedence over relayed data per the standard overlap logic.","tags":["Meetings"],"parameters":[{"name":"id","in":"path","required":true,"description":"Meeting ID","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["guestEmail","slots"],"properties":{"guestEmail":{"type":"string","format":"email","description":"The invited guest's email address. Must match an existing (non-revoked) invite on this meeting."},"slots":{"type":"array","items":{"$ref":"#/components/schemas/AvailabilitySlot"},"description":"Array of availability time slots to record for the guest."}}}}}},"responses":{"200":{"description":"Availability relayed","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"saved":{"type":"boolean","example":true}}}}}}}},"400":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Caller is not the meeting organizer","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Meeting not found, or guestEmail is not an invited participant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/meetings/relay-availability":{"post":{"summary":"Relay availability using a guest's invite token (guest-agent path)","security":[],"description":"Allows a caller presenting a guest's invite token to submit availability on that guest's behalf — e.g. the guest's own conversational agent responding to an invite programmatically. This endpoint does not require a WonderCal Bearer token; the invite token itself is the credential and is scoped to exactly one invite. Rejected if the invite has been revoked, or if it has been claimed by a different WonderCal account (the OAuth account takes precedence once claimed).","tags":["Meetings"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["meetingId","inviteToken","slots"],"properties":{"meetingId":{"type":"string","description":"Meeting ID."},"inviteToken":{"type":"string","description":"The guest's invite token, from their invite email link."},"slots":{"type":"array","items":{"$ref":"#/components/schemas/AvailabilitySlot"},"description":"Array of availability time slots to record for the guest."}}}}}},"responses":{"200":{"description":"Availability relayed","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"saved":{"type":"boolean","example":true}}}}}}}},"400":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Invite is revoked or claimed by another account","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Meeting not found, or invite token not recognized for this meeting","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/meetings/{id}/invite-link":{"get":{"summary":"Get meeting invite link","description":"Returns the meeting link and whether it is shareable. Only participants and the organizer can retrieve the link. When shareable is true, the meeting is unconfirmed and the link can be shared with invitees (and with anyone, if the organizer has enabled link-join). When shareable is false, the meeting is confirmed, it is the organizer's view/manage link, and it should not be shared.","tags":["Meetings"],"parameters":[{"name":"id","in":"path","required":true,"description":"Meeting ID","schema":{"type":"string"}}],"responses":{"200":{"description":"Invite link","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"url":{"type":"string","format":"uri","description":"The meeting URL.","example":"https://wondercal.com/m/abc123"},"shareable":{"type":"boolean","description":"True when the meeting is unconfirmed and can still be joined (by invitees, and by anyone if the organizer has enabled link-join). False once the meeting is confirmed, in which case the URL is the organizer's view/manage link — not for sharing."}}}}}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"User is not a participant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Meeting not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/meetings/{id}/participant-sources":{"get":{"summary":"Get per-participant availability source attribution","description":"Returns the availability source for each participant on the meeting. Only the meeting organizer can access this endpoint. source values: 'own-submission' (participant entered their own availability), 'organizer-agent-relayed' (organizer's agent submitted on the participant's behalf), 'guest-agent-submitted' (participant's own agent submitted via the token path), 'calendar-derived' (reserved, not currently produced), 'unknown' (legacy record with no attribution data).","tags":["Meetings"],"parameters":[{"name":"id","in":"path","required":true,"description":"Meeting ID","schema":{"type":"string"}}],"responses":{"200":{"description":"Participant source attributions","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"email":{"type":"string","format":"email"},"source":{"type":"string","enum":["own-submission","organizer-agent-relayed","guest-agent-submitted","calendar-derived","unknown"],"description":"How this participant's availability was submitted"}},"required":["email","source"]}}}}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Only the organizer can access this endpoint","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Meeting not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/me":{"get":{"summary":"Get current user profile","description":"Returns the authenticated user's profile. Sensitive fields such as tokens are excluded.","tags":["User"],"responses":{"200":{"description":"User profile","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/UserProfile"}}},"example":{"data":{"id":"j57dqc0anqnfmab9v72a5ym3ah77aq0r","name":"Jane Doe","email":"jane@example.com","image":"https://example.com/avatar.jpg","timeZone":"America/New_York","settings":{"defaultAvailableHours":{"start":"09:00","end":"17:00"},"defaultAvailableDaysOfWeek":[1,2,3,4,5],"bufferBetweenEvents":15,"leadTimeHours":4,"defaultConferencingLink":"https://zoom.us/j/1234567890","defaultConferencingProvider":null}}}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/me/settings":{"get":{"summary":"Get user settings","description":"Returns the authenticated user's scheduling settings.","tags":["User"],"responses":{"200":{"description":"User settings","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/UserSettings"}}}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}},"patch":{"summary":"Update user settings","description":"Partially updates the authenticated user's scheduling settings. Only provided fields are changed. A `meet.google.com` URL is stored as the Google Meet preference — a fresh link is generated per meeting — and is returned as `defaultConferencingProvider: \"google-meet\"` with `defaultConferencingLink: null`. `defaultConferencingProvider` is read-only: it is derived from the link you send, never set directly.","tags":["User"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"Partial update. Only provided fields are changed. Send null to clear a field.","properties":{"timeZone":{"type":"string","nullable":true,"description":"IANA timezone identifier (e.g. 'America/New_York'). Send null to clear.","example":"America/New_York"},"bufferBetweenEvents":{"type":"integer","nullable":true,"minimum":0,"maximum":1440,"description":"Buffer time in minutes between events (0-1440). Send null to clear.","example":15},"leadTimeHours":{"type":"integer","nullable":true,"minimum":0,"maximum":720,"description":"Minimum advance notice in hours before a meeting may be scheduled (0-720). Send null to clear.","example":4},"defaultAvailableHours":{"nullable":true,"description":"Default available hours with start and end in HH:MM format. Send null to clear.","oneOf":[{"$ref":"#/components/schemas/AvailableHours"},{"type":"object","nullable":true}],"example":{"start":"09:00","end":"17:00"}},"defaultAvailableDaysOfWeek":{"type":"array","nullable":true,"items":{"type":"integer","minimum":0,"maximum":6},"description":"Days of the week (0 = Sunday, 6 = Saturday). Must contain 1-7 unique values. Send null to clear.","example":[1,2,3,4,5]},"defaultConferencingLink":{"type":"string","nullable":true,"maxLength":500,"description":"Default conferencing link (max 500 characters). Send null to clear.","example":"https://meet.google.com/abc-def-ghi"}}}}}},"responses":{"200":{"description":"Settings updated","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/UserSettings"}}}}}},"400":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"413":{"description":"Request body exceeds 1 MB limit","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/me/calendars":{"get":{"summary":"List connected calendars","description":"Returns the authenticated user's connected Google Calendar accounts.","tags":["User"],"responses":{"200":{"description":"Connected calendars","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Calendar"}}}}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/me/free-busy":{"get":{"summary":"Get busy time slots","description":"Returns busy time slots from the authenticated user's blocking-enabled calendars (Google and Microsoft) for a date range. Only calendars with blocking enabled are included. To find OPEN time instead, use GET /api/me/free-time.","tags":["User"],"parameters":[{"name":"start","in":"query","required":true,"description":"Range start (ISO 8601 date-time).","schema":{"type":"string","format":"date-time","example":"2026-09-07T00:00:00Z"}},{"name":"end","in":"query","required":true,"description":"Range end (ISO 8601 date-time). Must be after start; the range must not exceed 90 days.","schema":{"type":"string","format":"date-time","example":"2026-09-13T23:59:59Z"}}],"responses":{"200":{"description":"Busy slots for the requested range","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/FreeBusyData"}}}}}},"400":{"description":"Validation error (missing/invalid start or end, or range exceeds 90 days)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/me/free-time":{"get":{"summary":"Find your free time (\"when am I free?\")","description":"Returns the authenticated user's OPEN time slots within a date range — the gaps between calendar events, within working hours, sized to `duration`. This is the single-person case of meeting overlap and reuses the same scheduling engine, so buffer time, working hours, and DST behave identically. Slots in the past are never returned. To list BUSY events instead, use GET /api/me/free-busy.","tags":["User"],"parameters":[{"name":"start","in":"query","required":true,"description":"Range start (ISO 8601 date-time). Clamped forward to the current time.","schema":{"type":"string","format":"date-time","example":"2026-09-07T00:00:00Z"}},{"name":"end","in":"query","required":true,"description":"Range end (ISO 8601 date-time). Must be after start; the range must not exceed 90 days.","schema":{"type":"string","format":"date-time","example":"2026-09-13T23:59:59Z"}},{"name":"duration","in":"query","required":false,"description":"Length of each free slot to return, in minutes (the appointment length). Defaults to 30.","schema":{"type":"integer","minimum":1,"default":30,"example":30}},{"name":"respectWorkingHours","in":"query","required":false,"description":"When true (default), only openings inside the user's available days and hours are returned. Set to 'false' to search the whole day, including evenings and weekends.","schema":{"type":"boolean","default":true}}],"responses":{"200":{"description":"Free slots for the requested range","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/FreeTimeData"}}}}}},"400":{"description":"Validation error (missing/invalid start or end, range exceeds 90 days, or non-positive duration)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/me/contacts":{"get":{"summary":"List or search contacts","description":"Returns the authenticated user's contacts. When the 'q' parameter is provided, searches contacts by name (case-insensitive prefix matching). Without 'q', returns all contacts sorted by most recently met.","tags":["Contacts"],"parameters":[{"name":"q","in":"query","required":false,"description":"Name search query (1-100 characters, prefix matching)","schema":{"type":"string","minLength":1,"maxLength":100}},{"name":"limit","in":"query","required":false,"description":"Maximum results per page (default 50, max 100)","schema":{"type":"integer","minimum":1,"maximum":100,"default":50}},{"name":"cursor","in":"query","required":false,"description":"Pagination cursor from a previous response (list mode only, not used with 'q')","schema":{"type":"string"}}],"responses":{"200":{"description":"List of contacts (with optional pagination cursor)","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"description":"When 'q' is provided: array of matching contacts. When 'q' is not provided: object with contacts array and pagination cursor.","oneOf":[{"type":"array","items":{"$ref":"#/components/schemas/Contact"},"description":"Search results (when 'q' is provided)"},{"type":"object","required":["contacts","nextCursor"],"properties":{"contacts":{"type":"array","items":{"$ref":"#/components/schemas/Contact"}},"nextCursor":{"type":"string","nullable":true,"description":"Cursor for next page, or null if no more results. Always present in list mode."}},"description":"Paginated list (when 'q' is not provided). Always includes nextCursor (null when no more pages)."}]}}}}}},"400":{"description":"Validation error (invalid q, limit, or cursor)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/openapi.json":{"get":{"summary":"Get OpenAPI specification","description":"Returns the OpenAPI 3.0.3 specification for this API in JSON format. This endpoint is public and requires no authentication.","security":[],"tags":["Meta"],"responses":{"200":{"description":"OpenAPI specification JSON","content":{"application/json":{"schema":{"type":"object","description":"OpenAPI 3.0.3 specification object"}}}}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"Bearer token obtained from POST /api/auth/token. Include as: Authorization: Bearer <token>"}},"schemas":{"ErrorResponse":{"type":"object","properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Machine-readable error code","example":"VALIDATION_ERROR"},"message":{"type":"string","description":"Human-readable error message","example":"Field 'title' is required"}}}}},"TokenExchangeRequest":{"type":"object","description":"Provide either apiKey or refreshToken, not both.","properties":{"apiKey":{"type":"string","description":"API key starting with 'wcal_'","example":"wcal_aBcDeFgHiJkLmNoPqRsTuVwXyZ012345678901234"},"refreshToken":{"type":"string","description":"Refresh token from a previous token exchange","example":"v02.local.abc123..."}}},"TokenExchangeResponse":{"type":"object","required":["token","refreshToken"],"properties":{"token":{"type":"string","description":"Short-lived Bearer token","example":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."},"refreshToken":{"type":"string","description":"Long-lived refresh token for obtaining new Bearer tokens","example":"v02.local.abc123..."}}},"CreateApiKeyRequest":{"type":"object","required":["name"],"properties":{"name":{"type":"string","description":"Human-readable name for the API key","minLength":1,"maxLength":100,"example":"My Integration"}}},"ApiKeyCreated":{"type":"object","properties":{"id":{"type":"string","description":"API key ID","example":"k5gmdr9txcq6dqaasj5q9p0d5h7753xh"},"key":{"type":"string","description":"Plaintext API key (shown once, cannot be retrieved later)","example":"wcal_aBcDeFgHiJkLmNoPqRsTuVwXyZ012345678901234"},"name":{"type":"string","description":"Key name","example":"My Integration"},"createdAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp","example":"2026-01-15T10:30:00.000Z"}}},"ApiKeyListItem":{"type":"object","properties":{"id":{"type":"string","description":"API key ID","example":"k5gmdr9txcq6dqaasj5q9p0d5h7753xh"},"name":{"type":"string","description":"Key name","example":"My Integration"},"createdAt":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2026-01-15T10:30:00.000Z"},"lastUsedAt":{"type":"string","format":"date-time","nullable":true,"description":"ISO 8601 timestamp of last use, or null if never used","example":"2026-02-20T14:22:00.000Z"}}},"WebhookEventType":{"type":"string","description":"The set of event types a webhook endpoint may subscribe to.","enum":["meeting.confirmed","meeting.canceled","meeting.rescheduled","participant.joined","participant.declined","availability.all_received","invite.bounced"]},"CreateWebhookEndpointRequest":{"type":"object","required":["url","subscribedEvents"],"properties":{"url":{"type":"string","format":"uri","description":"Must be https://","example":"https://api.example.com/webhooks/wondercal"},"subscribedEvents":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/WebhookEventType"},"description":"At least one event type is required."}}},"WebhookEndpointListItem":{"type":"object","properties":{"id":{"type":"string"},"url":{"type":"string","format":"uri"},"subscribedEvents":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEventType"}},"status":{"type":"string","enum":["active","disabled"]},"createdAt":{"type":"string","format":"date-time"}}},"WebhookEndpointCreated":{"type":"object","properties":{"endpointId":{"type":"string"},"url":{"type":"string","format":"uri"},"subscribedEvents":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEventType"}},"secretKey":{"type":"string","description":"Plaintext signing secret. Shown once; store securely."},"status":{"type":"string","enum":["active"]},"createdAt":{"type":"string","format":"date-time"}}},"WebhookSecretRotated":{"type":"object","properties":{"secretKey":{"type":"string","description":"New plaintext signing secret. Shown once; store securely."}}},"WebhookDeliveryListItem":{"type":"object","properties":{"id":{"type":"string","description":"Webhook delivery ID"},"eventType":{"type":"string","description":"Event type this delivery carries"},"eventId":{"type":"string","description":"Unique event ID (idempotency key)"},"timestamp":{"type":"string","format":"date-time","description":"When the event originally fired"},"state":{"type":"string","enum":["pending","retrying","in_flight","delivered","dead_letter"],"description":"Current delivery state"},"attemptCount":{"type":"integer","description":"Number of delivery attempts made so far"},"lastStatusCode":{"type":["integer","null"],"description":"HTTP status code of the most recent attempt, or null if it timed out or hit a connection error"},"lastError":{"type":["string","null"],"description":"Description of the most recent attempt's failure, or null if the last attempt succeeded"}}},"WebhookDeliveryListResponse":{"type":"object","properties":{"deliveries":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDeliveryListItem"}},"nextCursor":{"type":["string","null"],"description":"Opaque cursor for the next page, or null when there are no more results"}}},"MeetingRef":{"type":"object","properties":{"id":{"type":"string"},"title":{"type":"string"},"duration":{"type":"integer","description":"Minutes"},"status":{"type":"string","enum":["confirmed","confirming","pending","canceled"]}}},"WebhookParticipantRef":{"type":"object","properties":{"email":{"type":"string","nullable":true},"name":{"type":"string","nullable":true},"isGuest":{"type":"boolean"},"userId":{"type":"string","nullable":true}}},"WebhookPayloadEnvelope":{"type":"object","description":"Every webhook delivery body has this envelope shape, with `data` varying by eventType.","required":["eventType","eventId","timestamp","apiVersion","environment","data"],"properties":{"eventType":{"$ref":"#/components/schemas/WebhookEventType"},"eventId":{"type":"string","description":"Unique per event occurrence; use for at-least-once dedupe."},"timestamp":{"type":"string","format":"date-time"},"apiVersion":{"type":"string","example":"2026-07-30"},"environment":{"type":"string","description":"Deployment environment that emitted this event. \"production\" or \"sandbox\" in normal operation. The field is a developer convenience hint passed through from the DEPLOYMENT_ENVIRONMENT env var — no enum constraint is enforced at the API layer."},"data":{"description":"Shape varies by eventType — see the per-event schemas below."}}},"MeetingConfirmedEventPayload":{"type":"object","properties":{"meeting":{"$ref":"#/components/schemas/MeetingRef"},"selectedTime":{"type":"integer","description":"Unix ms"}}},"MeetingCanceledEventPayload":{"type":"object","properties":{"meeting":{"$ref":"#/components/schemas/MeetingRef"},"reason":{"type":"string","enum":["deleted","unscheduled"]}}},"MeetingRescheduledEventPayload":{"type":"object","properties":{"meeting":{"$ref":"#/components/schemas/MeetingRef"},"previousTime":{"type":"integer","nullable":true,"description":"Unix ms"},"rescheduleDeadline":{"type":"integer","nullable":true,"description":"Unix ms"},"sequence":{"type":"integer","description":"Mirrors meeting.icsSequence; use to resolve out-of-order delivery."}}},"ParticipantJoinedEventPayload":{"type":"object","properties":{"meeting":{"$ref":"#/components/schemas/MeetingRef"},"participant":{"$ref":"#/components/schemas/WebhookParticipantRef"}}},"ParticipantDeclinedEventPayload":{"type":"object","properties":{"meeting":{"$ref":"#/components/schemas/MeetingRef"},"participant":{"$ref":"#/components/schemas/WebhookParticipantRef"}}},"AvailabilityAllReceivedEventPayload":{"type":"object","properties":{"meeting":{"$ref":"#/components/schemas/MeetingRef"},"participants":{"type":"array","items":{"$ref":"#/components/schemas/WebhookParticipantRef"}},"totalCount":{"type":"integer"},"truncated":{"type":"boolean","description":"True if participants was capped (WEBHOOK_MAX_PARTICIPANTS_IN_PAYLOAD)."}}},"InviteBouncedEventPayload":{"type":"object","properties":{"meeting":{"$ref":"#/components/schemas/MeetingRef"},"invite":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"}}},"bounceType":{"type":"string","enum":["bounced","complained"]}}},"Meeting":{"type":"object","properties":{"id":{"type":"string","description":"Meeting ID","example":"m5gmdr9txcq6dqaasj5q9p0d5h7753xh"},"title":{"type":"string","description":"Meeting title","example":"Weekly Standup"},"duration":{"type":"integer","description":"Duration in minutes","example":30},"description":{"type":"string","nullable":true,"description":"Meeting description","example":"Team sync meeting"},"location":{"type":"string","nullable":true,"description":"Where the meeting will happen — a conferencing link, room name, or address","example":"https://meet.google.com/abc-def-ghi"},"status":{"type":"string","enum":["pending","confirmed"],"description":"Meeting status","example":"pending"},"createdAt":{"type":"string","format":"date-time","description":"ISO 8601 creation timestamp","example":"2026-01-15T10:30:00.000Z"}}},"MeetingDetail":{"type":"object","description":"Full meeting details including participants","properties":{"id":{"type":"string","example":"m5gmdr9txcq6dqaasj5q9p0d5h7753xh"},"title":{"type":"string","example":"Weekly Standup"},"duration":{"type":"integer","example":30},"description":{"type":"string","nullable":true,"example":"Team sync"},"location":{"type":"string","nullable":true,"example":null},"status":{"type":"string","enum":["pending","confirmed"],"example":"pending"},"createdAt":{"type":"string","format":"date-time","example":"2026-01-15T10:30:00.000Z"},"participants":{"type":"array","items":{"type":"string"},"description":"Array of participant user IDs","example":["userId1","userId2"]}}},"CreateMeetingRequest":{"type":"object","required":["title","duration"],"properties":{"title":{"type":"string","minLength":1,"maxLength":200,"description":"Meeting title","example":"Weekly Standup"},"duration":{"type":"integer","minimum":5,"maximum":480,"description":"Duration in minutes (5 to 480)","example":30},"description":{"type":"string","maxLength":2000,"nullable":true,"description":"Optional description","example":"Team sync meeting"},"location":{"type":"string","maxLength":500,"nullable":true,"description":"Optional — where the meeting will happen (a conferencing link, room name, or address)","example":null}}},"UpdateMeetingRequest":{"type":"object","description":"Partial update. Only provided fields are changed.","properties":{"title":{"type":"string","minLength":1,"maxLength":200,"example":"Updated Standup"},"duration":{"type":"integer","minimum":5,"maximum":480,"example":45},"description":{"type":"string","maxLength":2000,"nullable":true,"example":"Updated description"},"location":{"type":"string","maxLength":500,"nullable":true,"example":"Room 101"}}},"ConfirmMeetingRequest":{"type":"object","required":["time"],"properties":{"time":{"type":"string","format":"date-time","description":"ISO 8601 datetime for the confirmed meeting time","example":"2026-02-25T14:00:00.000Z"}}},"ConfirmResponse":{"type":"object","properties":{"confirmed":{"type":"boolean","example":true},"time":{"type":"string","format":"date-time","example":"2026-02-25T14:00:00.000Z"},"googleCalendarCreated":{"type":"boolean","description":"Whether a Google Calendar event was created","example":true},"emailSent":{"type":"boolean","description":"Whether notification emails were sent","example":true},"calendarLink":{"type":"string","nullable":true,"description":"Link to the Google Calendar event, if created","example":"https://calendar.google.com/event?id=abc123"},"errors":{"type":"array","items":{"type":"string"},"description":"Non-fatal errors during confirmation (e.g., email send failure)","example":[]}}},"AvailabilitySlot":{"type":"object","required":["start","end"],"properties":{"start":{"type":"string","format":"date-time","description":"Slot start time in ISO 8601 format","example":"2026-02-25T09:00:00.000Z"},"end":{"type":"string","format":"date-time","description":"Slot end time in ISO 8601 format","example":"2026-02-25T10:00:00.000Z"},"isBlocked":{"type":"boolean","description":"Whether this slot is blocked (calendar event)","example":false}}},"OverlapSlot":{"type":"object","properties":{"start":{"type":"string","format":"date-time","example":"2026-02-25T14:00:00.000Z"},"end":{"type":"string","format":"date-time","example":"2026-02-25T15:00:00.000Z"}}},"UserProfile":{"type":"object","description":"User profile. Excludes sensitive internal fields.","properties":{"id":{"type":"string","description":"User ID","example":"j57dqc0anqnfmab9v72a5ym3ah77aq0r"},"name":{"type":"string","nullable":true,"description":"Display name","example":"Jane Doe"},"email":{"type":"string","nullable":true,"description":"Email address","example":"jane@example.com"},"image":{"type":"string","nullable":true,"description":"Profile image URL","example":"https://example.com/avatar.jpg"},"timeZone":{"type":"string","nullable":true,"description":"IANA timezone identifier","example":"America/New_York"},"settings":{"$ref":"#/components/schemas/UserSettingsNested"}}},"UserSettingsNested":{"type":"object","description":"User settings as nested in the profile response","properties":{"defaultAvailableHours":{"$ref":"#/components/schemas/AvailableHours"},"defaultAvailableDaysOfWeek":{"type":"array","nullable":true,"items":{"type":"integer","minimum":0,"maximum":6},"description":"Days of the week (0 = Sunday, 6 = Saturday)","example":[1,2,3,4,5]},"bufferBetweenEvents":{"type":"integer","nullable":true,"minimum":0,"maximum":1440,"description":"Buffer time in minutes between events","example":15},"leadTimeHours":{"type":"integer","nullable":true,"minimum":0,"maximum":720,"description":"Minimum advance notice in hours before a meeting may be scheduled","example":4},"defaultConferencingLink":{"type":"string","nullable":true,"maxLength":500,"description":"Default conferencing link for meetings","example":null},"defaultConferencingProvider":{"type":"string","nullable":true,"enum":["google-meet"],"description":"Read-only. \"google-meet\" when the user's default is Google Meet — a fresh link is generated per meeting, so defaultConferencingLink is null. Derived from the link on write; never settable directly.","example":null}}},"UserSettings":{"type":"object","description":"User scheduling settings (top-level response shape)","properties":{"defaultAvailableHours":{"$ref":"#/components/schemas/AvailableHours"},"defaultAvailableDaysOfWeek":{"type":"array","nullable":true,"items":{"type":"integer","minimum":0,"maximum":6},"description":"Days of the week (0 = Sunday, 6 = Saturday)","example":[1,2,3,4,5]},"bufferBetweenEvents":{"type":"integer","nullable":true,"minimum":0,"maximum":1440,"description":"Buffer time in minutes between events","example":15},"leadTimeHours":{"type":"integer","nullable":true,"minimum":0,"maximum":720,"description":"Minimum advance notice in hours before a meeting may be scheduled","example":4},"timeZone":{"type":"string","nullable":true,"description":"IANA timezone identifier","example":"America/New_York"},"defaultConferencingLink":{"type":"string","nullable":true,"maxLength":500,"description":"Default conferencing link for meetings","example":null},"defaultConferencingProvider":{"type":"string","nullable":true,"enum":["google-meet"],"description":"Read-only. \"google-meet\" when the user's default is Google Meet — a fresh link is generated per meeting, so defaultConferencingLink is null. Derived from the link on write; never settable directly.","example":null}}},"AvailableHours":{"type":"object","nullable":true,"required":["start","end"],"properties":{"start":{"type":"string","pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","description":"Start time in HH:MM format","example":"09:00"},"end":{"type":"string","pattern":"^([01]\\d|2[0-3]):[0-5]\\d$","description":"End time in HH:MM format (must be after start)","example":"17:00"}}},"Calendar":{"type":"object","properties":{"id":{"type":"string","description":"Calendar ID","example":"primary"},"summary":{"type":"string","description":"Calendar display name","example":"jane@example.com"},"primary":{"type":"boolean","description":"Whether this is the user's primary calendar","example":true}}},"BusySlot":{"type":"object","description":"A single busy interval from a connected calendar.","required":["start","end","calendarId","eventId"],"properties":{"start":{"type":"string","format":"date-time","description":"Busy interval start (ISO 8601)."},"end":{"type":"string","format":"date-time","description":"Busy interval end (ISO 8601)."},"title":{"type":"string","nullable":true,"description":"Event title, or null when unavailable."},"calendarId":{"type":"string","description":"ID of the calendar the event belongs to."},"eventId":{"type":"string","description":"Calendar event ID. Pass with calendarId to unblock-event tooling."},"recurringEventId":{"type":"string","description":"Present only when this is an instance of a recurring series."}}},"FreeBusyData":{"type":"object","description":"Payload of GET /api/me/free-busy (wrapped in the top-level `data` field).","required":["start","end","busySlots"],"properties":{"start":{"type":"string","format":"date-time","description":"Echoed range start."},"end":{"type":"string","format":"date-time","description":"Echoed range end."},"busySlots":{"type":"array","items":{"$ref":"#/components/schemas/BusySlot"},"description":"Busy intervals within the range, from blocking-enabled calendars."}}},"FreeSlot":{"type":"object","description":"A single open time slot.","required":["start","end"],"properties":{"start":{"type":"string","format":"date-time","description":"Slot start (ISO 8601)."},"end":{"type":"string","format":"date-time","description":"Slot end (ISO 8601) — start plus the requested duration."}}},"FreeTimeData":{"type":"object","description":"Payload of GET /api/me/free-time (wrapped in the top-level `data` field).","required":["durationMinutes","respectWorkingHours","timeZone","calendarConnected","slots"],"properties":{"durationMinutes":{"type":"integer","description":"The slot length that was used (echoes the `duration` param, or the default).","example":30},"respectWorkingHours":{"type":"boolean","description":"Whether the search was limited to the user's working hours and days."},"timeZone":{"type":"string","description":"IANA timezone the slots are computed in.","example":"America/New_York"},"calendarConnected":{"type":"boolean","description":"False when no calendar is connected — slots then reflect working hours only, not real events."},"slots":{"type":"array","items":{"$ref":"#/components/schemas/FreeSlot"},"description":"Open time slots within the range, sized to durationMinutes."}}},"Contact":{"type":"object","properties":{"id":{"type":"string","description":"Contact record ID"},"userId":{"type":"string","description":"User ID of the contact person"},"name":{"type":"string","nullable":true,"description":"Contact's display name"},"email":{"type":"string","nullable":true,"description":"Contact's email address"},"firstMet":{"type":"string","format":"date-time","description":"ISO 8601 timestamp of first shared meeting"},"lastMet":{"type":"string","format":"date-time","description":"ISO 8601 timestamp of most recent shared meeting"},"meetingCount":{"type":"integer","description":"Number of meetings shared with this contact"}}}},"headers":{"IdempotencyReplayed":{"description":"Present (value 'true') when this response was served from the idempotency cache. Absent on first-execution responses.","schema":{"type":"string","enum":["true"]}}},"parameters":{"IdempotencyKeyHeader":{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","maxLength":255},"description":"Optional idempotency key (max 255 chars). When provided, retrying the same request within 24 hours with the same key and identical body returns the cached response instead of re-executing. Do not embed sensitive data in the key value — keys may appear in server logs on certain error paths. Use a distinct key per endpoint call — the key is scoped to your user account only, not to the endpoint URL, so reusing the same key across different endpoint calls may return an incorrect cached response."},"IdempotencyKeyHeaderPathScoped":{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","maxLength":255},"description":"Optional idempotency key (max 255 chars). When provided, retrying the same request within 24 hours with the same key and identical body returns the cached response instead of re-executing. Do not embed sensitive data in the key value — keys may appear in server logs on certain error paths. Use a distinct key per endpoint call — the key is scoped to your user account and key value, NOT to the resource URL or endpoint path, so reusing the same key across different endpoint calls or different resource URLs may return an incorrect cached response."}},"responses":{"RateLimited":{"description":"Rate limit exceeded. The Retry-After header indicates how many seconds to wait before retrying.","headers":{"Retry-After":{"description":"Seconds to wait before retrying","schema":{"type":"integer","example":60}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":{"code":"RATE_LIMITED","message":"Too many requests. Please try again later."}}}}},"IdempotencyConflict":{"description":"Idempotency-Key conflict. Two possible cases: (1) Permanent conflict — the same key was previously used with a different request body. Use a new key for this request. No Retry-After header is present. (2) Transient conflict — a request with the same key is still being processed. The Retry-After header indicates when to retry the identical request.","headers":{"Retry-After":{"description":"Present only on transient conflicts (concurrent in-progress request). Seconds to wait before retrying.","schema":{"type":"integer","example":5}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":{"code":"IDEMPOTENCY_KEY_CONFLICT","message":"This Idempotency-Key was already used with a different request body."}}}}}}}}