{"components":{"parameters":{"after":{"description":"Opaque cursor for pagination (obtained from previous response's `next_cursor`).\nCursors are base64url-encoded strings - treat as opaque, do not parse or modify.\nExample: `MTcwNjg4NjAwMDAwMDAwMDAwMDowMUhZWDNLUVc3RVJUV jlYTkJNMlA4UUpaRg`\n","in":"query","name":"after","schema":{"example":"MTcwNjg4NjAwMDAwMDAwMDAwMDowMUhZWDNLUVc3RVJUV jlYTkJNMlA4UUpaRg","type":"string"}},"city":{"description":"Filter by city/locality","in":"query","name":"city","schema":{"type":"string"}},"endDate":{"description":"Filter events starting on or before this date (ISO8601, e.g. 2026-06-30). Snake_case alias end_date is accepted but triggers a warnings entry in the response.\n","in":"query","name":"endDate","schema":{"format":"date","type":"string"}},"eventDomain":{"description":"Filter by event domain","in":"query","name":"domain","schema":{"enum":["arts","music","culture","sports","community","education","general"],"type":"string"}},"eventId":{"description":"Event ULID","in":"path","name":"id","required":true,"schema":{"pattern":"^[0-9A-HJKMNP-TV-Z]{26}$","type":"string"}},"keywords":{"description":"Filter by keywords (comma-separated)","in":"query","name":"keywords","schema":{"type":"string"}},"lifecycleState":{"description":"Filter by lifecycle state","in":"query","name":"state","schema":{"enum":["draft","published","postponed","rescheduled","sold_out","cancelled","completed"],"type":"string"}},"limit":{"description":"Maximum results per page (default 50, max 200)","in":"query","name":"limit","schema":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"organizerId":{"description":"Filter by organizer ULID. Snake_case alias organizer_id is accepted but triggers a warnings entry in the response.\n","in":"query","name":"organizerId","schema":{"type":"string"}},"query":{"description":"Free-text search on name and description","in":"query","name":"q","schema":{"type":"string"}},"startDate":{"description":"Filter events starting on or after this date (ISO8601, e.g. 2026-06-01). Defaults to today when neither startDate nor endDate is provided, so past events are excluded by default. \"Today\" is midnight in the server timezone (configurable via DEFAULT_TIMEZONE, default America/Toronto). Pass an explicit startDate to override. Snake_case alias start_date is accepted but triggers a warnings entry in the response.\n","in":"query","name":"startDate","schema":{"format":"date","type":"string"}},"venueId":{"description":"Filter by venue ULID. Snake_case alias venue_id is accepted but triggers a warnings entry in the response.\n","in":"query","name":"venueId","schema":{"type":"string"}}},"responses":{"BadRequest":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Invalid request"},"Forbidden":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Insufficient permissions"},"Gone":{"content":{"application/ld+json":{"schema":{"$ref":"#/components/schemas/Tombstone"}}},"description":"Resource deleted"},"NotFound":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Resource not found"},"Unauthorized":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Authentication required"}},"schemas":{"APIKeyUsage":{"description":"Detailed usage statistics for an API key over a date range","properties":{"api_key_id":{"description":"API key UUID","format":"uuid","type":"string"},"api_key_name":{"description":"API key name","type":"string"},"daily":{"description":"Daily breakdown of usage statistics","items":{"properties":{"date":{"format":"date","type":"string"},"error_count":{"example":3,"type":"integer"},"request_count":{"example":1523,"type":"integer"}},"required":["date","request_count","error_count"],"type":"object"},"type":"array"},"period":{"properties":{"from":{"description":"Start date of usage period","format":"date","type":"string"},"to":{"description":"End date of usage period","format":"date","type":"string"}},"required":["from","to"],"type":"object"},"total_errors":{"description":"Total number of errors (4xx/5xx) in the period","example":142,"type":"integer"},"total_requests":{"description":"Total number of requests in the period","example":34210,"type":"integer"}},"required":["api_key_id","api_key_name","period","total_requests","total_errors","daily"],"type":"object"},"AdminUserResponse":{"description":"Admin user representation returned by user management endpoints.","properties":{"created_at":{"description":"When the user account was created","format":"date-time","type":"string"},"email":{"description":"User's email address","format":"email","type":"string"},"id":{"description":"User's unique identifier","format":"uuid","type":"string"},"is_active":{"description":"Whether the user account is active","type":"boolean"},"last_login_at":{"description":"When the user last logged in, or null if never","format":"date-time","type":["string","null"]},"role":{"description":"User's role","enum":["admin","editor","viewer"],"type":"string"},"status":{"description":"Derived user status. active: account is active; pending: account created but invitation not yet accepted (no password set); inactive: account was active but has been deactivated","enum":["active","inactive","pending"],"type":"string"},"username":{"description":"User's login username","type":"string"}},"required":["id","username","email","role","is_active","status","created_at"],"type":"object"},"AllDiagnostics":{"description":"Cross-source diagnostics with filtered recent runs","properties":{"items":{"items":{"$ref":"#/components/schemas/ScraperRunDetail"},"type":"array"},"total":{"description":"Total number of runs returned","type":"integer"}},"type":"object"},"ApiKeyCreated":{"allOf":[{"$ref":"#/components/schemas/ApiKeyInfo"},{"properties":{"key":{"description":"Full API key (shown only once)","type":"string"}},"required":["key"],"type":"object"}],"description":"Response when creating a new API key, includes the secret (shown only once)"},"ApiKeyInfo":{"description":"API key metadata (without secret) for agent authentication management","properties":{"createdAt":{"format":"date-time","type":"string"},"expiresAt":{"format":"date-time","type":"string"},"id":{"format":"uuid","type":"string"},"isActive":{"type":"boolean"},"lastUsedAt":{"format":"date-time","type":"string"},"name":{"type":"string"},"prefix":{"description":"First 8 chars of key","type":"string"},"sourceId":{"format":"uuid","type":"string"}},"type":"object"},"ChangeDetail":{"description":"Record of a single field change made during event normalization","properties":{"corrected":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"object"},{"type":"array"},{"type":"null"}],"description":"Corrected value after normalization (can be any type)","example":"2026-03-16T22:00:00Z"},"field":{"description":"Field that was changed (e.g., \"endDate\")","type":"string"},"original":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"object"},{"type":"array"},{"type":"null"}],"description":"Original value before normalization (can be any type)","example":"2026-03-15T22:00:00Z"},"reason":{"description":"Explanation of why the change was made","type":"string"}},"required":["field","original","corrected","reason"],"type":"object"},"ChangeEntry":{"description":"Single change record in the federation feed with action type and snapshot","properties":{"action":{"enum":["create","update","delete"],"type":"string"},"changed_at":{"format":"date-time","type":"string"},"changed_fields":{"items":{"type":"string"},"type":"array"},"snapshot":{"$ref":"#/components/schemas/Event"},"uri":{"format":"uri","type":"string"}},"required":["action","uri","changed_at"],"type":"object"},"ChangeFeedResponse":{"description":"Federation change feed containing sequential updates for event synchronization","properties":{"changes":{"items":{"$ref":"#/components/schemas/ChangeEntry"},"type":"array"},"cursor":{"description":"Current cursor position","type":"string"},"next_cursor":{"description":"Cursor for next page","type":"string"}},"required":["cursor","changes"],"type":"object"},"ConsolidateResponse":{"description":"Result of an atomic event consolidation operation","properties":{"event":{"$ref":"#/components/schemas/Event","description":"The canonical event after consolidation"},"is_duplicate":{"description":"True if the canonical event was flagged as a near-duplicate of another non-retired event during post-consolidation validation.\n","type":"boolean"},"is_merged":{"description":"Always false for consolidation (admin intent is preserved, never auto-merged).\n","type":"boolean"},"lifecycle_state":{"description":"Lifecycle state of the canonical event after consolidation","enum":["published","pending_review","draft"],"type":"string"},"needs_review":{"description":"True if the canonical event was sent to the review queue due to duplicate detection or quality issues found during post-consolidation validation.\n","type":"boolean"},"retired":{"description":"ULIDs of events that were successfully retired (soft-deleted)","items":{"type":"string"},"type":"array"},"review_entries_dismissed":{"description":"IDs of review queue entries that were dismissed for retired events","items":{"type":"integer"},"type":"array"},"warnings":{"description":"Validation warnings from post-consolidation checks","items":{"$ref":"#/components/schemas/ValidationWarning"},"type":"array"}},"type":"object"},"Developer":{"description":"Developer account with API key management capabilities","properties":{"created_at":{"description":"When the developer account was created","format":"date-time","type":"string"},"email":{"description":"Developer's email address","format":"email","type":"string"},"github_username":{"description":"GitHub username if linked via OAuth","type":"string"},"id":{"description":"Developer UUID","format":"uuid","type":"string"},"is_active":{"description":"Whether the developer account is active","type":"boolean"},"last_login_at":{"description":"When the developer last logged in","format":"date-time","type":"string"},"max_keys":{"description":"Maximum API keys this developer can create","maximum":20,"minimum":1,"type":"integer"},"name":{"description":"Developer's full name","type":"string"}},"required":["id","email","name","max_keys","is_active","created_at"],"type":"object"},"DeveloperAPIKey":{"description":"Developer-owned API key with usage statistics (secret not included)","properties":{"created_at":{"description":"When the key was created","format":"date-time","type":"string"},"id":{"description":"API key UUID","format":"uuid","type":"string"},"is_active":{"description":"Whether the key is currently active","type":"boolean"},"last_used_at":{"description":"When the key was last used","format":"date-time","type":"string"},"name":{"description":"Human-readable name for the key","type":"string"},"prefix":{"description":"First 8 characters of the key for identification","example":"01KGJZ40","type":"string"},"role":{"description":"API key role (always 'agent' for developer keys)","enum":["agent"],"type":"string"},"usage_30d":{"description":"Number of requests in the last 30 days","example":34210,"type":"integer"},"usage_7d":{"description":"Number of requests in the last 7 days","example":8420,"type":"integer"},"usage_today":{"description":"Number of requests made today","example":1523,"type":"integer"}},"required":["id","name","prefix","role","is_active","created_at"],"type":"object"},"DeveloperAPIKeyCreated":{"description":"Response when creating a new API key, includes full secret (shown only once)","properties":{"created_at":{"format":"date-time","type":"string"},"id":{"format":"uuid","type":"string"},"key":{"description":"Full API key secret (shown only once on creation)","example":"01KGJZ40ZG0WQ5SQ...","type":"string"},"name":{"type":"string"},"prefix":{"example":"01KGJZ40","type":"string"},"role":{"enum":["agent"],"type":"string"}},"required":["id","name","prefix","key","role","created_at"],"type":"object"},"Event":{"description":"A cultural event in Schema.org Event format with SEL extensions for provenance and federation","properties":{"@context":{"description":"JSON-LD context. May be a string URL, an array of URLs, or an embedded context object.\nPer JSON-LD specification, all three forms are valid.\n","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"},{"type":"object"}]},"@id":{"description":"Canonical URI for this event","format":"uri","type":"string"},"@type":{"enum":["Event","EventSeries"],"type":"string"},"description":{"type":"string"},"doorTime":{"format":"date-time","type":"string"},"endDate":{"format":"date-time","type":"string"},"eventAttendanceMode":{"format":"uri","type":"string"},"eventStatus":{"format":"uri","type":"string"},"image":{"format":"uri","type":"string"},"inLanguage":{"items":{"type":"string"},"type":"array"},"isAccessibleForFree":{"type":"boolean"},"license":{"format":"uri","type":"string"},"location":{"$ref":"#/components/schemas/Place"},"name":{"type":"string"},"offers":{"$ref":"#/components/schemas/Offer"},"organizer":{"$ref":"#/components/schemas/Organization"},"sameAs":{"items":{"format":"uri","type":"string"},"type":"array"},"sel:confidence":{"maximum":1,"minimum":0,"type":"number"},"sel:ingestedAt":{"format":"date-time","type":"string"},"sel:originNode":{"format":"uri","type":"string"},"startDate":{"format":"date-time","type":"string"},"url":{"format":"uri","type":"string"}},"required":["@id","@type","name","startDate","location"],"type":"object"},"EventInput":{"description":"Input schema for creating or updating an event. Minimal required fields with optional Schema.org properties","properties":{"description":{"maxLength":10000,"type":"string"},"doorTime":{"format":"date-time","type":"string"},"endDate":{"format":"date-time","type":"string"},"image":{"format":"uri","type":"string"},"inLanguage":{"items":{"type":"string"},"type":"array"},"isAccessibleForFree":{"type":"boolean"},"keywords":{"items":{"type":"string"},"type":"array"},"location":{"oneOf":[{"$ref":"#/components/schemas/PlaceInput"},{"$ref":"#/components/schemas/VirtualLocationInput"}]},"name":{"description":"Event name (configurable via VALIDATION_MAX_EVENT_NAME_LENGTH, default 500)","maxLength":500,"minLength":1,"type":"string"},"offers":{"$ref":"#/components/schemas/OfferInput"},"organizer":{"$ref":"#/components/schemas/OrganizationInput"},"source":{"$ref":"#/components/schemas/SourceInput"},"startDate":{"format":"date-time","type":"string"},"url":{"format":"uri","type":"string"},"virtualLocation":{"$ref":"#/components/schemas/VirtualLocationInput"}},"required":["name","startDate"],"type":"object"},"EventListResponse":{"description":"Paginated list of events with cursor-based pagination","properties":{"items":{"items":{"$ref":"#/components/schemas/Event"},"type":"array"},"next_cursor":{"description":"Cursor for next page","type":"string"},"warnings":{"description":"Parameter alias warnings. Present when a snake_case alias (e.g. start_date) was used instead of the canonical camelCase name. Each entry names the alias used and the correct parameter name. Omitted when no aliases were detected.\n","items":{"type":"string"},"type":"array"}},"required":["items"],"type":"object"},"EventOccurrence":{"description":"A single occurrence (date/time/venue) of an event","properties":{"availability":{"description":"Availability status (e.g., InStock, OutOfStock)","type":"string"},"doorTime":{"description":"Doors open time (optional)","format":"date-time","type":["string","null"]},"endTime":{"description":"End date and time of the occurrence (optional)","format":"date-time","type":["string","null"]},"id":{"description":"Occurrence UUID","type":"string"},"priceCurrency":{"description":"ISO 4217 currency code (e.g., USD)","type":"string"},"priceMax":{"description":"Maximum ticket price (optional)","format":"float","type":["number","null"]},"priceMin":{"description":"Minimum ticket price (optional)","format":"float","type":["number","null"]},"startTime":{"description":"Start date and time of the occurrence","format":"date-time","type":"string"},"ticketUrl":{"description":"URL to purchase tickets (optional)","type":"string"},"timezone":{"description":"IANA timezone identifier (e.g., America/New_York)","type":"string"},"venueUlid":{"description":"ULID of the venue (optional)","type":["string","null"]},"virtualUrl":{"description":"Virtual event URL (optional)","type":["string","null"]}},"required":["id","startTime","timezone"],"type":"object"},"EventUpdateInput":{"allOf":[{"$ref":"#/components/schemas/EventInput"},{"properties":{"lifecycleState":{"enum":["draft","published","postponed","rescheduled","sold_out","cancelled","completed"],"type":"string"},"version":{"description":"Current version for optimistic locking","type":"integer"}},"type":"object"}],"description":"Schema for updating an existing event with version control for optimistic locking"},"GeoCoordinates":{"description":"Geographic coordinates (latitude/longitude) using Schema.org GeoCoordinates vocabulary","properties":{"@type":{"enum":["GeoCoordinates"],"type":"string"},"latitude":{"type":"number"},"longitude":{"type":"number"}},"type":"object"},"OccurrenceInput":{"description":"Input for creating or updating an event occurrence","properties":{"availability":{"description":"Ticket availability status","enum":["available","sold_out","limited"],"type":"string"},"door_time":{"description":"Door open time (RFC3339), or null to clear","format":"date-time","type":["string","null"]},"end_time":{"description":"End time of the occurrence (RFC3339), or null to clear","format":"date-time","type":["string","null"]},"price_currency":{"description":"ISO 4217 currency code (e.g. USD)","type":"string"},"price_max":{"description":"Maximum ticket price, or null to clear","type":["number","null"]},"price_min":{"description":"Minimum ticket price (inclusive), or null to clear","type":["number","null"]},"start_time":{"description":"Start time of the occurrence (RFC3339)","format":"date-time","type":"string"},"ticket_url":{"description":"URL for purchasing tickets, or null to clear","format":"uri","type":["string","null"]},"timezone":{"description":"IANA timezone identifier (e.g. America/New_York)","type":"string"},"venue_ulid":{"description":"ULID of the venue for this occurrence, or null to clear","type":["string","null"]},"virtual_url":{"description":"URL for virtual/hybrid attendance, or null to clear","format":"uri","type":["string","null"]}},"type":"object"},"OccurrenceResponse":{"description":"An event occurrence as returned by the admin occurrence API","properties":{"availability":{"type":["string","null"]},"door_time":{"format":"date-time","type":["string","null"]},"end_time":{"format":"date-time","type":["string","null"]},"id":{"description":"UUID of the occurrence","format":"uuid","type":"string"},"price_currency":{"type":["string","null"]},"price_max":{"type":["number","null"]},"price_min":{"type":["number","null"]},"start_time":{"format":"date-time","type":"string"},"ticket_url":{"format":"uri","type":["string","null"]},"timezone":{"type":"string"},"venue_id":{"description":"Internal UUID of the venue","type":["string","null"]},"venue_ulid":{"description":"Public ULID of the venue","type":["string","null"]},"virtual_url":{"format":"uri","type":["string","null"]}},"type":"object"},"Offer":{"description":"Ticketing or pricing information using Schema.org Offer vocabulary","properties":{"@type":{"enum":["Offer"],"type":"string"},"availability":{"format":"uri","type":"string"},"price":{"type":"string"},"priceCurrency":{"type":"string"},"url":{"format":"uri","type":"string"}},"type":"object"},"OfferInput":{"description":"Input schema for ticket/pricing offers","properties":{"price":{"type":"string"},"priceCurrency":{"default":"CAD","type":"string"},"url":{"format":"uri","type":"string"}},"type":"object"},"Organization":{"description":"An organization or person that organizes events, using Schema.org Organization vocabulary","properties":{"@id":{"format":"uri","type":"string"},"@type":{"enum":["Organization"],"type":"string"},"alternateName":{"type":"string"},"name":{"type":"string"},"sameAs":{"items":{"format":"uri","type":"string"},"type":"array"},"url":{"format":"uri","type":"string"}},"required":["@type","name"],"type":"object"},"OrganizationInput":{"description":"Input schema for specifying event organizers","properties":{"@id":{"format":"uri","type":"string"},"name":{"type":"string"},"url":{"format":"uri","type":"string"}},"required":["name"],"type":"object"},"OrganizationListResponse":{"description":"Paginated list of organizations with cursor-based pagination","properties":{"items":{"items":{"$ref":"#/components/schemas/Organization"},"type":"array"},"next_cursor":{"type":"string"},"warnings":{"description":"Parameter alias warnings. See EventListResponse.warnings for details.","items":{"type":"string"},"type":"array"}},"required":["items"],"type":"object"},"Place":{"description":"A physical location or venue where events occur, using Schema.org Place vocabulary","properties":{"@id":{"format":"uri","type":"string"},"@type":{"enum":["Place"],"type":"string"},"address":{"$ref":"#/components/schemas/PostalAddress"},"geo":{"$ref":"#/components/schemas/GeoCoordinates"},"name":{"type":"string"},"sameAs":{"items":{"format":"uri","type":"string"},"type":"array"},"telephone":{"type":"string"},"url":{"format":"uri","type":"string"}},"required":["@type","name"],"type":"object"},"PlaceInput":{"description":"Input schema for specifying event locations with address and geographic coordinates","properties":{"@id":{"description":"Reference to existing place by URI","format":"uri","type":"string"},"addressCountry":{"type":"string"},"addressLocality":{"type":"string"},"addressRegion":{"type":"string"},"latitude":{"type":"number"},"longitude":{"type":"number"},"name":{"type":"string"},"postalCode":{"type":"string"},"streetAddress":{"type":"string"}},"required":["name"],"type":"object"},"PlaceListResponse":{"description":"Paginated list of places (venues) with cursor-based pagination","properties":{"items":{"items":{"$ref":"#/components/schemas/Place"},"type":"array"},"next_cursor":{"type":"string"},"warnings":{"description":"Parameter alias warnings. See EventListResponse.warnings for details.","items":{"type":"string"},"type":"array"}},"required":["items"],"type":"object"},"PostalAddress":{"description":"Physical mailing address using Schema.org PostalAddress vocabulary","properties":{"@type":{"enum":["PostalAddress"],"type":"string"},"addressCountry":{"type":"string"},"addressLocality":{"type":"string"},"addressRegion":{"type":"string"},"postalCode":{"type":"string"},"streetAddress":{"type":"string"}},"type":"object"},"ProblemDetails":{"description":"RFC 7807 Problem Details","properties":{"detail":{"description":"Human-readable explanation","example":"You have reached your maximum of 5 API keys. Revoke an existing key or contact an admin to increase your limit.","type":"string"},"instance":{"description":"URI identifying the specific occurrence","example":"/api/v1/dev/api-keys","type":"string"},"status":{"description":"HTTP status code","example":409,"type":"integer"},"title":{"description":"Short, human-readable summary","example":"Maximum API keys exceeded","type":"string"},"type":{"description":"URI identifying the problem type","example":"https://sel.events/problems/max-keys-exceeded","format":"uri","type":"string"}},"required":["type","title","status"],"type":"object"},"RelatedEvent":{"description":"A related event identified from warnings or duplicate link, including full event data for side-by-side comparison","properties":{"description":{"description":"Event description (optional)","type":["string","null"]},"imageUrl":{"description":"Image URL for the event (optional)","type":["string","null"]},"name":{"description":"Event name","type":"string"},"occurrences":{"description":"List of occurrences for the related event","items":{"$ref":"#/components/schemas/EventOccurrence"},"type":"array"},"organizerName":{"description":"Name of the organizer (optional)","type":["string","null"]},"organizerUrl":{"description":"URL of the organizer (optional)","type":["string","null"]},"similarity":{"description":"Similarity score from duplicate-warning details, if available (0–1)","format":"float","type":["number","null"]},"ulid":{"description":"ULID of the related event","type":"string"},"url":{"description":"Public URL of the event (optional)","type":["string","null"]},"venueCity":{"description":"City of the venue (optional)","type":["string","null"]},"venueName":{"description":"Name of the primary venue (optional)","type":["string","null"]},"venuePostalCode":{"description":"Postal code of the venue (optional)","type":["string","null"]},"venueRegion":{"description":"Province/region of the venue (optional)","type":["string","null"]},"venueStreetAddress":{"description":"Street address of the venue (optional)","type":["string","null"]},"venueUlid":{"description":"ULID of the primary venue (optional)","type":["string","null"]}},"required":["ulid"],"type":"object"},"ReviewQueueDetail":{"description":"Detailed review queue entry including original payload, normalized payload, and detected changes","properties":{"changes":{"description":"List of changes made during normalization","items":{"$ref":"#/components/schemas/ChangeDetail"},"type":"array"},"createdAt":{"description":"When the entry was created","format":"date-time","type":"string"},"duplicateOfEventUlid":{"description":"ULID of the primary event that this review entry was merged into or linked as a near-duplicate of. Set when the review action is add-occurrence or consolidation, and also when near-duplicate cross-linking creates a review entry for an existing published event.\n","type":"string"},"eventId":{"description":"Event ULID","type":"string"},"id":{"description":"Review queue entry ID","type":"integer"},"normalized":{"description":"Normalized event payload after corrections","type":"object"},"occurrences":{"description":"List of occurrences for the event associated with this review entry","items":{"$ref":"#/components/schemas/EventOccurrence"},"type":"array"},"original":{"description":"Original event payload as submitted","type":"object"},"rejectionReason":{"description":"Reason for rejection (if rejected)","type":"string"},"relatedEvents":{"description":"Related events identified from warnings or duplicate link","items":{"$ref":"#/components/schemas/RelatedEvent"},"type":"array"},"reviewNotes":{"description":"Notes from the reviewer (if approved with notes)","type":"string"},"reviewedAt":{"description":"When the review was completed (if reviewed)","format":"date-time","type":"string"},"reviewedBy":{"description":"Email of admin who reviewed (if reviewed)","type":"string"},"status":{"description":"Current review status. `pending` = awaiting decision; `approved` = event published; `rejected` = event deleted; `merged` = event absorbed into another event (via add-occurrence or consolidation).\n","enum":["pending","approved","rejected","merged"],"type":"string"},"warnings":{"description":"Validation warnings that triggered review","items":{"$ref":"#/components/schemas/ValidationWarning"},"type":"array"}},"required":["id","eventId","status","warnings","original","normalized","changes","createdAt","occurrences","relatedEvents"],"type":"object"},"ReviewQueueItem":{"description":"Summary of a review queue entry with event ID and reason for review","properties":{"createdAt":{"description":"When the entry was created","format":"date-time","type":"string"},"duplicateOfEventUlid":{"description":"ULID of the primary event that this review entry was merged into or linked as a near-duplicate of. Set when the review action is add-occurrence or consolidation, and also when near-duplicate cross-linking creates a review entry for an existing published event.\n","type":"string"},"eventEndTime":{"description":"Event end time","format":"date-time","type":"string"},"eventId":{"description":"Event ULID","type":"string"},"eventName":{"description":"Name of the event (from original payload)","type":"string"},"eventStartTime":{"description":"Event start time","format":"date-time","type":"string"},"id":{"description":"Review queue entry ID","type":"integer"},"occurrenceCount":{"description":"Number of occurrences for the event. Fetched from the live event data to reflect current occurrence information.\n","type":"integer"},"reviewedAt":{"description":"When the review was completed (if reviewed)","format":"date-time","type":"string"},"reviewedBy":{"description":"Email of admin who reviewed (if reviewed)","type":"string"},"status":{"description":"Current review status. `pending` = awaiting decision; `approved` = event published; `rejected` = event deleted; `merged` = event absorbed into another event (via add-occurrence or consolidation).\n","enum":["pending","approved","rejected","merged"],"type":"string"},"warnings":{"description":"Validation warnings that triggered review","items":{"$ref":"#/components/schemas/ValidationWarning"},"type":"array"}},"required":["id","eventId","warnings","status","createdAt"],"type":"object"},"ReviewQueueListResponse":{"description":"Paginated list of events awaiting admin review","properties":{"items":{"items":{"$ref":"#/components/schemas/ReviewQueueItem"},"type":"array"},"next_cursor":{"description":"Cursor for next page (review queue entry ID)","type":"string"}},"required":["items"],"type":"object"},"ScraperConfig":{"description":"Global scraper configuration settings","properties":{"auto_scrape":{"default":true,"description":"Whether automatic scheduled scraping is enabled","type":"boolean"},"max_batch_size":{"default":100,"description":"Maximum number of events to batch per source","type":"integer"},"max_concurrent_sources":{"default":3,"description":"Maximum number of sources to scrape concurrently","type":"integer"},"rate_limit_ms":{"default":0,"description":"Minimum delay between requests in milliseconds","type":"integer"},"request_timeout_seconds":{"default":30,"description":"HTTP request timeout in seconds","type":"integer"},"retry_max_attempts":{"default":3,"description":"Maximum retry attempts for failed requests","type":"integer"}},"type":"object"},"ScraperConfigPatch":{"description":"Partial scraper configuration for PATCH requests (all fields optional)","properties":{"auto_scrape":{"type":"boolean"},"max_batch_size":{"description":"Must be greater than 0","type":"integer"},"max_concurrent_sources":{"description":"Must be greater than 0","type":"integer"},"rate_limit_ms":{"description":"Must be 0 or greater","type":"integer"},"request_timeout_seconds":{"description":"Must be greater than 0","type":"integer"},"retry_max_attempts":{"description":"Must be greater than 0","type":"integer"}},"type":"object"},"ScraperRunDetail":{"description":"A single scraper run record","properties":{"completed_at":{"format":"date-time","type":"string"},"error_message":{"type":"string"},"events_dup":{"type":"integer"},"events_failed":{"type":"integer"},"events_found":{"type":"integer"},"events_new":{"type":"integer"},"id":{"type":"integer"},"source_name":{"type":"string"},"source_url":{"format":"uri","type":"string"},"started_at":{"format":"date-time","type":"string"},"status":{"type":"string"},"tier":{"type":"integer"}},"type":"object"},"ScraperSourceSummary":{"description":"A scraper source with its latest run summary","properties":{"enabled":{"type":"boolean"},"id":{"type":"integer"},"last_run_completed_at":{"format":"date-time","type":"string"},"last_run_error_message":{"type":"string"},"last_run_events_dup":{"type":"integer"},"last_run_events_failed":{"type":"integer"},"last_run_events_found":{"type":"integer"},"last_run_events_new":{"type":"integer"},"last_run_started_at":{"format":"date-time","type":"string"},"last_run_status":{"type":"string"},"license":{"type":"string"},"name":{"type":"string"},"schedule":{"type":"string"},"tier":{"type":"integer"},"url":{"format":"uri","type":"string"}},"type":"object"},"SourceDiagnostics":{"description":"Diagnostics for a single scraper source including latest run, last successful run, and recent history","properties":{"last_successful_run":{"description":"The most recent completed run (included when latest_run failed, for comparison)","oneOf":[{"$ref":"#/components/schemas/ScraperRunDetail"},{"type":"null"}]},"latest_run":{"description":"The most recent run for this source (null if no runs exist)","oneOf":[{"$ref":"#/components/schemas/ScraperRunDetail"},{"type":"null"}]},"recent_runs":{"description":"Recent runs ordered newest first (limited by query param)","items":{"$ref":"#/components/schemas/ScraperRunDetail"},"type":"array"},"source_name":{"type":"string"}},"type":"object"},"SourceInput":{"description":"Provenance metadata identifying the data source and attribution for submitted events","properties":{"eventId":{"description":"External system's event ID","type":"string"},"url":{"description":"Source URL for provenance","format":"uri","type":"string"}},"type":"object"},"Submission":{"description":"A URL submission record","properties":{"id":{"format":"int64","type":"integer"},"notes":{"description":"Admin review notes","type":["string","null"]},"rejection_reason":{"description":"Set when the batch validator rejects the URL","type":["string","null"]},"status":{"enum":["pending_validation","pending","processed","rejected"],"type":"string"},"submitted_at":{"format":"date-time","type":"string"},"submitter_ip":{"description":"IP address of the submitter","type":"string"},"url":{"description":"Original submitted URL","format":"uri","type":"string"},"url_norm":{"description":"Normalised URL (trailing slash stripped, lowercased scheme/host)","format":"uri","type":"string"},"validated_at":{"description":"Timestamp when the batch validator processed this submission","format":"date-time","type":["string","null"]}},"type":"object"},"SubmissionResult":{"description":"Per-URL result from the public submission endpoint","properties":{"message":{"description":"Human-readable explanation","type":"string"},"status":{"description":"`accepted` — stored for validation; `duplicate` — already submitted\nrecently; `rejected` — failed basic validation (invalid URL, etc.)\n","enum":["accepted","duplicate","rejected"],"type":"string"},"url":{"description":"The URL that was submitted","format":"uri","type":"string"}},"type":"object"},"Tombstone":{"description":"Response for deleted entities (HTTP 410)","properties":{"@context":{"type":"string"},"@id":{"format":"uri","type":"string"},"@type":{"type":"string"},"eventStatus":{"format":"uri","type":"string"},"sel:deletedAt":{"format":"date-time","type":"string"},"sel:deletionReason":{"type":"string"},"sel:supersededBy":{"format":"uri","type":"string"},"sel:tombstone":{"type":"boolean"}},"type":"object"},"ValidationWarning":{"description":"Warning message from event validation or normalization process","properties":{"code":{"description":"Warning code (e.g., \"REVERSED_DATES\", \"MISSING_END_DATE\")","type":"string"},"field":{"description":"Field path that triggered the warning","type":"string"},"message":{"description":"Human-readable warning message","type":"string"},"occurrences":{"description":"List of occurrences for the event under review. These are fetched from the live event data (not from the ingest snapshot) to ensure the UI shows current occurrence information for dedup decision-making. Empty if the event has no occurrences (e.g., single-instance long-duration occurrence).\n","items":{"$ref":"#/components/schemas/EventOccurrence"},"type":"array"},"relatedEvents":{"description":"List of related events extracted from validation warnings (potential_duplicate, near_duplicate_of_new_event) and from the duplicateOfEventUlid field. Each entry includes full event data (name, description, URL, venue, occurrences, similarity) so the UI can render the side-by-side comparison panel without additional API calls. Used to determine which case applies (Case 1: empty list, Case 2/3: non-empty list).\n","items":{"$ref":"#/components/schemas/RelatedEvent"},"type":"array"},"severity":{"description":"Warning severity level","enum":["info","warning","error"],"type":"string"}},"required":["code","message"],"type":"object"},"VirtualLocationInput":{"description":"Input schema for online/virtual event locations with URL","properties":{"@type":{"enum":["VirtualLocation"],"type":"string"},"name":{"type":"string"},"url":{"format":"uri","type":"string"}},"required":["url"],"type":"object"}},"securitySchemes":{"apiKey":{"description":"API key for agent access","scheme":"bearer","type":"http"},"bearerAuth":{"bearerFormat":"JWT","description":"JWT token for admin access","scheme":"bearer","type":"http"},"cookieAuth":{"description":"Admin JWT token in HttpOnly cookie (used by same-origin SSE endpoints)","in":"cookie","name":"auth_token","type":"apiKey"},"devCookie":{"description":"Developer JWT token in HttpOnly cookie","in":"cookie","name":"dev_auth_token","type":"apiKey"},"devJWT":{"bearerFormat":"JWT","description":"Developer JWT token for API access","scheme":"bearer","type":"http"}}},"info":{"contact":{"email":"hello@togather.foundation","name":"SEL Team","url":"https://togather.foundation"},"description":"RESTful API for the Shared Events Library, a federated events commons \nenabling discovery, submission, and management of cultural events.\n\n## Getting an API Key\n\n**Public read access:** No authentication required for reading events, places, or organizations.\n\n**Authenticated write access:** To submit or manage events, you need an API key.\n\n### Two Onboarding Paths\n\n#### Option 1: Self-Service via GitHub OAuth (Instant)\n\n1. Visit `/dev/login` and click \"Sign in with GitHub\"\n2. Authorize the app on GitHub\n3. Your developer account is automatically created\n4. Navigate to `/dev/api-keys` to create your first API key\n\n#### Option 2: Email Invitation (Email/Password)\n\n1. **Request an invitation:** Email [info@togather.foundation](mailto:info@togather.foundation) with:\n   - Your name\n   - Email address\n   - Intended use case\n\n2. **Accept invitation:**\n   - Check your email for the invitation link (valid for 7 days)\n   - Set your password and display name\n   - You'll be logged in to the developer dashboard at `/dev/login`\n\n3. **Create an API key:**\n   - Navigate to `/dev/api-keys`\n   - Click \"Create New Key\"\n   - Enter a descriptive name\n   - Copy the key immediately (shown only once)\n   - Store securely (environment variable or secret manager)\n\n### Using Your API Key\n\n**Via Web Interface:**\n1. Navigate to `/dev/api-keys`\n2. Click \"Create New Key\"\n3. Enter a descriptive name\n4. Copy the key immediately (shown only once)\n5. Store securely (environment variable or secret manager)\n\n**Via API:**\n```bash\n# First, get your JWT token (if using email/password)\ncurl -X POST https://toronto.togather.foundation/api/v1/dev/login \\\n     -H \"Content-Type: application/json\" \\\n     -d '{\"email\": \"your-email@example.com\", \"password\": \"your-password\"}'\n\n# Then create a key\ncurl -X POST https://toronto.togather.foundation/api/v1/dev/api-keys \\\n     -H \"Authorization: Bearer $YOUR_JWT_TOKEN\" \\\n     -H \"Content-Type: application/json\" \\\n     -d '{\"name\": \"My Event Scraper\"}'\n```\n\n### Making Authenticated Requests\n\n**Reading events (no auth):**\n```bash\ncurl https://toronto.togather.foundation/api/v1/events\n```\n\n**Submitting events (with API key):**\n```bash\ncurl -X POST https://toronto.togather.foundation/api/v1/events \\\n     -H \"Authorization: Bearer your_api_key_here\" \\\n     -H \"Content-Type: application/ld+json\" \\\n     -d @event.json\n```\n\n**For complete guidance**, see:\n- [Developer Quick Start](https://github.com/Togather-Foundation/server/blob/main/docs/integration/DEVELOPER_QUICKSTART.md) (4-step onboarding)\n- [Authentication Guide](https://github.com/Togather-Foundation/server/blob/main/docs/integration/AUTHENTICATION.md) (comprehensive auth docs)\n- [API Guide](https://github.com/Togather-Foundation/server/blob/main/docs/integration/API_GUIDE.md) (endpoint reference)\n\n## Authentication\n- **Public**: No authentication required for read endpoints\n- **Agents**: API key in `Authorization: Bearer \u003ckey\u003e` header\n- **Admins**: JWT token in `Authorization: Bearer \u003ctoken\u003e` header\n- **Developers**: JWT token in `dev_auth_token` cookie or `Authorization: Bearer \u003ctoken\u003e` header\n\n## Rate Limits\nAll public (unauthenticated) read endpoints share a single rate limit tier, configurable\nvia `RATE_LIMIT_PUBLIC` (default 60 requests per minute per IP). Individual endpoints\nmay have tighter per-endpoint limits:\n- `POST /api/v1/dev/login`: configurable via `RATE_LIMIT_LOGIN`, default 5 attempts per 15-minute window per IP\n- `POST /scraper/submissions`: configurable via `RATE_LIMIT_SUBMISSIONS_PER_IP_PER_24H`, default 20 URLs per 24 hours per IP\n\n## Content Negotiation\nAPI endpoints return JSON/JSON-LD via `Accept` header:\n- `application/ld+json` - JSON-LD (default)\n- `application/json` - JSON-LD (alias)\n\n## Pagination\nList endpoints use cursor-based pagination with `after` and `limit` parameters.\nDefault limit is 50, maximum is 200.\n\n### Cursor Format\nCursors are opaque base64url-encoded strings (RFC 4648, URL-safe, no padding).\n- **Event cursors**: Encode timestamp + ULID for stable ordering\n- **Change feed cursors**: Encode sequence number for federation sync\n\n**Important**: Treat cursors as opaque strings. Use the `next_cursor` value from\nAPI responses as-is. Do not parse, decode, or modify cursor values.\n\nExample pagination:\n```\nGET /api/v1/events?limit=50\n-\u003e returns { items: [...], next_cursor: \"MTcwNjg4NjAwMD...\" }\n\nGET /api/v1/events?limit=50\u0026after=MTcwNjg4NjAwMD...\n-\u003e returns next page\n```\n","license":{"name":"CC0 1.0","url":"https://creativecommons.org/publicdomain/zero/1.0/"},"title":"Shared Events Library (SEL) API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/.well-known/sel-profile":{"get":{"description":"Returns this node's SEL profile information for federation discovery.\nRequired by SEL Interoperability Profile §1.7.\n","operationId":"getSELProfile","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"node":{"example":"https://toronto.togather.foundation","format":"uri","type":"string"},"profile":{"example":"https://togather.foundation/sel/profiles/v0.1","format":"uri","type":"string"},"updated":{"format":"date-time","type":"string"},"version":{"example":"0.1.0","type":"string"}},"required":["profile","version","node","updated"],"type":"object"}}},"description":"SEL profile information"}},"security":[],"summary":"SEL node profile discovery","tags":["Health"]}},"/admin/api-keys":{"get":{"description":"Retrieve all API keys (without secrets). Requires admin authentication.","operationId":"listApiKeys","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ApiKeyInfo"},"type":"array"}},"type":"object"}}},"description":"List of API keys (without secrets)"}},"security":[{"bearerAuth":[]}],"summary":"List API keys","tags":["Admin"],"x-internal":true},"post":{"description":"Generate a new API key for agent authentication. Returns the API key secret only once. Requires admin authentication.","operationId":"createApiKey","requestBody":{"content":{"application/json":{"schema":{"properties":{"expiresAt":{"format":"date-time","type":"string"},"name":{"type":"string"},"sourceId":{"format":"uuid","type":"string"}},"required":["name"],"type":"object"}}},"description":"API key name and optional metadata (sourceId, expiresAt)","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyCreated"}}},"description":"API key created (secret shown once)"}},"security":[{"bearerAuth":[]}],"summary":"Create a new API key","tags":["Admin"],"x-internal":true}},"/admin/developers":{"get":{"description":"Retrieve a list of all developers with their API key count and usage statistics","operationId":"listDevelopers","parameters":[{"description":"Filter by developer status","in":"query","name":"status","schema":{"enum":["active","invited","deactivated"],"type":"string"}},{"$ref":"#/components/parameters/after"},{"$ref":"#/components/parameters/limit"}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"items":{"$ref":"#/components/schemas/Developer"},"type":"array"},"next_cursor":{"type":"string"}},"type":"object"}}},"description":"List of developers"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}},"security":[{"bearerAuth":[]}],"summary":"List all developers","tags":["Admin"],"x-internal":true}},"/admin/developers/invite":{"post":{"description":"Send an invitation to a developer by email. The developer receives an email with a link to accept the invitation.","operationId":"inviteDeveloper","requestBody":{"content":{"application/json":{"schema":{"properties":{"email":{"description":"Developer's email address","format":"email","type":"string"},"max_keys":{"default":5,"description":"Maximum API keys this developer can create","maximum":20,"minimum":1,"type":"integer"},"name":{"description":"Optional developer name","type":"string"}},"required":["email"],"type":"object"}}},"description":"Developer invitation details","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"properties":{"email":{"type":"string"},"id":{"format":"uuid","type":"string"},"invitation_expires_at":{"format":"date-time","type":"string"},"status":{"enum":["invited"],"type":"string"}},"type":"object"}}},"description":"Invitation sent"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Email already registered"}},"security":[{"bearerAuth":[]}],"summary":"Invite a developer","tags":["Admin"],"x-internal":true}},"/admin/developers/{id}":{"delete":{"description":"Deactivate developer and revoke all their API keys","operationId":"deleteDeveloper","parameters":[{"description":"Developer UUID","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"Developer deactivated"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}},"security":[{"bearerAuth":[]}],"summary":"Deactivate developer","tags":["Admin"],"x-internal":true},"get":{"description":"Retrieve details for a specific developer including their API keys","operationId":"getDeveloper","parameters":[{"description":"Developer UUID","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/Developer"},{"properties":{"api_keys":{"items":{"$ref":"#/components/schemas/DeveloperAPIKey"},"type":"array"}},"type":"object"}]}}},"description":"Developer details"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}},"security":[{"bearerAuth":[]}],"summary":"Get developer details","tags":["Admin"],"x-internal":true},"put":{"description":"Update developer's max_keys or is_active status","operationId":"updateDeveloper","parameters":[{"description":"Developer UUID","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"is_active":{"type":"boolean"},"max_keys":{"maximum":20,"minimum":1,"type":"integer"}},"type":"object"}}},"description":"Developer settings to update","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Developer"}}},"description":"Developer updated"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}},"security":[{"bearerAuth":[]}],"summary":"Update developer settings","tags":["Admin"],"x-internal":true}},"/admin/events/consolidate":{"post":{"description":"Consolidate N events into a single canonical event in one atomic operation.\nEither creates a new canonical event (`event`) or promotes an existing one\n(`event_ulid`). All events listed in `retire` are soft-deleted with tombstones.\nOpen review queue entries for retired events are dismissed automatically.\n\nPost-consolidation validation (duplicate detection, quality checks) runs on\nthe canonical event; results are returned in `warnings`, `is_duplicate`, and\n`needs_review`. Near-duplicate detection after consolidation uses a similarity\nthreshold configurable via DEDUP_NEAR_DUPLICATE_THRESHOLD (default 0.4).\n\nExactly one of the following must hold:\n- `event_ulid` only: promote an existing event as canonical with no field changes.\n- `event_ulid` + `event`: promote an existing event as canonical AND apply field\n  edits atomically inside the same transaction. Patchable fields: `name`,\n  `description`, `url`, `image`, `keywords`, `eventDomain`. Fields ignored on the\n  patch path: `startDate`, `endDate`, `doorTime`, `occurrences`, `source`,\n  `lifecycle_state`, and scraper-only hints.\n- `event` only: create a new canonical event (full ingest pipeline).\n\n`retire` is required and must contain at least one ULID. The canonical event's\nULID must not appear in the `retire` list.\n","operationId":"consolidateEvents","requestBody":{"content":{"application/json":{"schema":{"properties":{"event":{"$ref":"#/components/schemas/EventInput","description":"When `event_ulid` is absent: full event payload to create a new canonical event. When `event_ulid` is present: partial patch applied atomically to the promoted canonical event. Patchable fields: name, description, url, image, keywords, eventDomain. Occurrence-derived and ingest-only fields (startDate, endDate, occurrences, source, lifecycle_state) are ignored on the patch path.\n"},"event_ulid":{"description":"ULID of an existing event to promote as the canonical event. May be combined with `event` to apply field edits atomically.\n","type":"string"},"retire":{"description":"Event ULIDs to soft-delete with tombstones. All must exist and not already be deleted. Required, minimum 1 entry.\n","items":{"type":"string"},"minItems":1,"type":"array"},"transfer_occurrences":{"default":false,"description":"If true, occurrences are copied from each retired event to the canonical event before retirement. Returns 409 if any occurrence would overlap an existing one on the canonical. Default false (Merge Duplicate path — retire only, no occurrence transfer).\n","type":"boolean"}},"required":["retire"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsolidateResponse"}}},"description":"Consolidation succeeded"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Invalid request. Possible reasons:\n- Neither `event` nor `event_ulid` was provided\n- `retire` list is empty or missing\n- Canonical event's ULID appears in `retire`\n"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Retire target or canonical event not found"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"An occurrence from a retired event overlaps an existing occurrence on\nthe canonical event (only when transfer_occurrences is true).\n"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Unprocessable request. Possible reasons:\n- One or more retire targets are already deleted\n- The canonical event has been deleted\n- Validation error in the provided event payload\n"}},"security":[{"bearerAuth":[]}],"summary":"Atomically consolidate multiple events into one canonical event","tags":["Admin"],"x-internal":true}},"/admin/events/pending":{"get":{"description":"Returns events flagged for admin review (low confidence, validation issues, etc.)","operationId":"listPendingEvents","parameters":[{"$ref":"#/components/parameters/after"},{"$ref":"#/components/parameters/limit"}],"responses":{"200":{"content":{"application/ld+json":{"schema":{"$ref":"#/components/schemas/EventListResponse"}}},"description":"Paginated list of pending events"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}},"security":[{"bearerAuth":[]}],"summary":"List events awaiting review","tags":["Admin"],"x-internal":true}},"/admin/events/{id}":{"delete":{"description":"Soft delete - returns 410 Gone with tombstone thereafter","operationId":"deleteEvent","parameters":[{"$ref":"#/components/parameters/eventId"}],"responses":{"204":{"description":"Event deleted"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}},"security":[{"bearerAuth":[]}],"summary":"Delete an event","tags":["Admin"],"x-internal":true},"put":{"description":"Admin endpoint to edit event details. Increments version and logs change.","operationId":"updateEvent","parameters":[{"$ref":"#/components/parameters/eventId"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventUpdateInput"}}},"description":"Updated event data with version for optimistic locking","required":true},"responses":{"200":{"content":{"application/ld+json":{"schema":{"$ref":"#/components/schemas/Event"}}},"description":"Event updated"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Version conflict (optimistic locking)"}},"security":[{"bearerAuth":[]}],"summary":"Update an event","tags":["Admin"],"x-internal":true}},"/admin/events/{id}/occurrences":{"post":{"description":"Create a new occurrence on an existing event. The event must not be deleted.\nReturns 409 if the new occurrence overlaps an existing occurrence on the event.\n","operationId":"createOccurrence","parameters":[{"$ref":"#/components/parameters/eventId"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OccurrenceInput"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OccurrenceResponse"}}},"description":"Occurrence created"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Occurrence overlaps an existing occurrence"},"410":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Event has been deleted"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Validation error"}},"security":[{"bearerAuth":[]}],"summary":"Add an occurrence to an event","tags":["Admin"],"x-internal":true}},"/admin/events/{id}/occurrences/{occurrenceId}":{"delete":{"description":"Remove a single occurrence from an event. The last occurrence of an event\ncannot be deleted (returns 422); delete the event itself instead.\n","operationId":"deleteOccurrence","parameters":[{"$ref":"#/components/parameters/eventId"},{"description":"UUID of the occurrence to delete","in":"path","name":"occurrenceId","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"Occurrence deleted"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"410":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Event has been deleted"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Cannot delete the last occurrence of an event"}},"security":[{"bearerAuth":[]}],"summary":"Delete an occurrence from an event","tags":["Admin"],"x-internal":true},"put":{"description":"Update an existing occurrence. Supports partial updates via nullable fields.\nReturns 409 if the updated occurrence would overlap another occurrence on the event.\n","operationId":"updateOccurrence","parameters":[{"$ref":"#/components/parameters/eventId"},{"description":"UUID of the occurrence to update","in":"path","name":"occurrenceId","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OccurrenceInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OccurrenceResponse"}}},"description":"Occurrence updated"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Occurrence overlaps an existing occurrence"},"410":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Event has been deleted"},"422":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Validation error"}},"security":[{"bearerAuth":[]}],"summary":"Update an occurrence on an event","tags":["Admin"],"x-internal":true}},"/admin/login":{"post":{"description":"Authenticate admin user and receive JWT token","operationId":"login","requestBody":{"content":{"application/json":{"schema":{"properties":{"email":{"format":"email","type":"string"},"password":{"format":"password","type":"string"}},"required":["email","password"],"type":"object"}}},"description":"Admin credentials (email and password) for authentication","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"expiresAt":{"format":"date-time","type":"string"},"token":{"type":"string"}},"type":"object"}}},"description":"Login successful"},"401":{"$ref":"#/components/responses/Unauthorized"}},"summary":"Admin login","tags":["Admin"],"x-internal":true}},"/admin/review-queue":{"get":{"description":"Returns events awaiting admin review due to validation warnings or normalization changes.\nSupports filtering by status and pagination.\n","operationId":"listReviewQueue","parameters":[{"description":"Filter by review status (defaults to 'pending')","in":"query","name":"status","schema":{"default":"pending","enum":["pending","approved","rejected","merged"],"type":"string"}},{"description":"Pagination cursor (review queue entry ID)","in":"query","name":"cursor","schema":{"type":"string"}},{"description":"Maximum results per page (default 50, max 100)","in":"query","name":"limit","schema":{"default":50,"maximum":100,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewQueueListResponse"}}},"description":"Paginated list of review queue entries"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}},"security":[{"bearerAuth":[]}],"summary":"List review queue entries","tags":["Admin"],"x-internal":true}},"/admin/review-queue/{id}":{"get":{"description":"Retrieve detailed information for a specific review queue entry, including\noriginal payload, normalized payload, detected changes, and validation warnings.\n","operationId":"getReviewQueueEntry","parameters":[{"description":"Review queue entry ID","in":"path","name":"id","required":true,"schema":{"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewQueueDetail"}}},"description":"Review queue entry details"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}},"security":[{"bearerAuth":[]}],"summary":"Get review queue entry details","tags":["Admin"],"x-internal":true}},"/admin/review-queue/{id}/approve":{"post":{"description":"Approve an event in the review queue and publish it. The event's lifecycle\nstate will be updated to 'published' and it will appear in public listings.\n\nSet `record_not_duplicates: true` to implement the \"Not a Duplicate\" action:\nthe event is published and the duplicate-warning candidates are recorded as a\nconfirmed non-duplicate pair, suppressing re-flagging on future ingest.\nThis differs from a plain approve only in the side-effect on the\n`not_duplicates` table; the event transition is identical.\n","operationId":"approveReviewQueueEntry","parameters":[{"description":"Review queue entry ID","in":"path","name":"id","required":true,"schema":{"minimum":1,"type":"integer"}}],"requestBody":{"content":{"application/json":{"examples":{"notADuplicate":{"summary":"Approve as not-a-duplicate (suppresses future duplicate warnings)","value":{"notes":"Different event, same venue","record_not_duplicates":true}},"withNotes":{"summary":"Approval with notes","value":{"notes":"Date corrections look good, publishing"}},"withoutNotes":{"summary":"Simple approval","value":{}}},"schema":{"properties":{"notes":{"description":"Optional review notes","type":"string"},"record_not_duplicates":{"description":"When `true`, any `potential_duplicate` warning candidates in this\nreview entry are recorded as confirmed non-duplicates. Future ingest\nof events that match these pairs will not generate duplicate warnings.\nUsed by the \"Not a Duplicate\" UI action, which approves/publishes the\nevent while acknowledging that the duplicate flag was incorrect.\nMatched companion pending reviews are rechecked best-effort after the\nduplicate warnings are removed: if no issues remain they are\nauto-approved, otherwise they stay pending with refreshed warnings.\n","type":"boolean"}},"type":"object"}}},"description":"Optional review notes for approval","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewQueueDetail"}}},"description":"Review approved and event published"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Review entry has already been processed"},"410":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Event has been deleted"}},"security":[{"bearerAuth":[]}],"summary":"Approve a review queue entry","tags":["Admin"],"x-internal":true}},"/admin/review-queue/{id}/fix":{"post":{"description":"Apply date corrections to an event and approve it for publication.\nThis endpoint is intended for fixing date-related issues detected during normalization.\n\nNote: Currently this endpoint only marks the review as approved with correction notes.\nFull date correction implementation is tracked in srv-trg.\n","operationId":"fixReviewQueueEntry","parameters":[{"description":"Review queue entry ID","in":"path","name":"id","required":true,"schema":{"minimum":1,"type":"integer"}}],"requestBody":{"content":{"application/json":{"examples":{"fixBothDates":{"summary":"Fix both dates","value":{"corrections":{"endDate":"2026-03-15T22:00:00Z","startDate":"2026-03-15T19:00:00Z"},"notes":"Corrected timezone offset"}},"fixEndDate":{"summary":"Fix reversed end date","value":{"corrections":{"endDate":"2026-03-15T22:00:00Z"},"notes":"Added 24 hours to fix reversed dates"}}},"schema":{"properties":{"corrections":{"description":"Date corrections to apply","properties":{"endDate":{"description":"Corrected end date","format":"date-time","type":"string"},"startDate":{"description":"Corrected start date","format":"date-time","type":"string"}},"type":"object"},"notes":{"description":"Optional notes about the corrections","type":"string"}},"required":["corrections"],"type":"object"}}},"description":"Date corrections to apply to the event (startDate and/or endDate)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewQueueDetail"}}},"description":"Dates corrected and review approved"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Review entry has already been processed"},"410":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Event has been deleted"}},"security":[{"bearerAuth":[]}],"summary":"Fix event dates and approve","tags":["Admin"],"x-internal":true}},"/admin/review-queue/{id}/reject":{"post":{"description":"Reject an event in the review queue and mark it as deleted. The event will\nreturn HTTP 410 Gone with a tombstone and will not appear in public listings.\n","operationId":"rejectReviewQueueEntry","parameters":[{"description":"Review queue entry ID","in":"path","name":"id","required":true,"schema":{"minimum":1,"type":"integer"}}],"requestBody":{"content":{"application/json":{"examples":{"invalidDates":{"summary":"Reject due to invalid dates","value":{"reason":"Event dates are in the past and cannot be corrected"}},"spam":{"summary":"Reject spam event","value":{"reason":"Spam submission - not a legitimate event"}}},"schema":{"properties":{"reason":{"description":"Required rejection reason","type":"string"}},"required":["reason"],"type":"object"}}},"description":"Rejection reason (required) explaining why the event was rejected","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewQueueDetail"}}},"description":"Review rejected and event deleted"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Review entry has already been processed"},"410":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Event has already been deleted"}},"security":[{"bearerAuth":[]}],"summary":"Reject a review queue entry","tags":["Admin"],"x-internal":true}},"/admin/scraper/config":{"get":{"description":"Returns the current global scraper configuration including concurrency limits,\ntimeouts, retry settings, and rate limiting. If no config exists in the database,\nreturns sensible defaults.\n","operationId":"getScraperConfig","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScraperConfig"}}},"description":"Current scraper configuration"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Internal server error"}},"security":[{"bearerAuth":[]}],"summary":"Get global scraper configuration","tags":["Admin"],"x-internal":true},"patch":{"description":"Updates the global scraper configuration with partial JSON. Only provided fields\nare applied over the current config. All numeric fields must be greater than 0\n(except rate_limit_ms which can be 0).\n\nValidation rules:\n- max_concurrent_sources: must be \u003e 0\n- request_timeout_seconds: must be \u003e 0\n- retry_max_attempts: must be \u003e 0\n- max_batch_size: must be \u003e 0\n- rate_limit_ms: must be \u003e= 0\n","operationId":"patchScraperConfig","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScraperConfigPatch"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScraperConfig"}}},"description":"Updated scraper configuration"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Validation error"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Internal server error"}},"security":[{"bearerAuth":[]}],"summary":"Update global scraper configuration","tags":["Admin"],"x-internal":true}},"/admin/scraper/diagnostics":{"get":{"description":"Returns recent scraper runs across all sources with optional filters.\nUseful for troubleshooting failures across multiple sources at once.\n\nQuery parameters:\n- `limit`: Number of runs to return (1–100, default 20)\n- `status`: Filter by run status (failed, completed, running)\n- `source_name`: Filter by specific source name\n","operationId":"getAllDiagnostics","parameters":[{"description":"Number of runs to return (1–100, default 20)","in":"query","name":"limit","schema":{"default":20,"maximum":100,"minimum":1,"type":"integer"}},{"description":"Filter by run status","in":"query","name":"status","schema":{"enum":["failed","completed","running"],"type":"string"}},{"description":"Filter by source name","in":"query","name":"source_name","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllDiagnostics"}}},"description":"Filtered list of recent scraper runs"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Internal server error"}},"security":[{"bearerAuth":[]}],"summary":"Get cross-source scraper diagnostics","tags":["Admin"],"x-internal":true}},"/admin/scraper/events":{"get":{"description":"Server-Sent Events stream of River job lifecycle events for scraper jobs.\nClients receive events when scrape_source jobs complete, fail, or are cancelled.\nMaintains a keepalive comment every 15 seconds to prevent proxy timeouts.\nProtected by admin cookie auth (same-origin EventSource sends cookies automatically).\n","operationId":"streamScraperEvents","responses":{"200":{"content":{"text/event-stream":{"schema":{"description":"Newline-delimited SSE stream. Each event has the form:\n  id: \u003cjob_id\u003e\n  data: {\"kind\":\"job_completed\",\"job_kind\":\"scrape_source\",\"source_name\":\"\u003cname\u003e\",\"job_id\":\u003cid\u003e}\n\nKeepalive comments (`: keepalive`) are sent every 15 seconds.\nA `retry: 5000` directive is sent on connection.\n","type":"string"}}},"description":"SSE stream of scraper job events"},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"description":"Streaming not supported"}},"security":[{"cookieAuth":[]}],"summary":"Stream scraper job events","tags":["Admin"],"x-internal":true}},"/admin/scraper/sources":{"get":{"description":"Returns all scraper sources with their latest run summary (status, counts,\nerror message). Supports optional filtering by enabled state.\n","operationId":"listScraperSources","parameters":[{"description":"Filter by enabled state. Omit to return all sources.","in":"query","name":"enabled","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ScraperSourceSummary"},"type":"array"}},"type":"object"}}},"description":"List of scraper sources"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Internal server error"}},"security":[{"bearerAuth":[]}],"summary":"List scraper sources","tags":["Admin"],"x-internal":true}},"/admin/scraper/sources/{name}":{"patch":{"description":"Enables or disables the named scraper source. Returns the updated source.\n","operationId":"setScraperSourceEnabled","parameters":[{"description":"Scraper source name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"enabled":{"type":"boolean"}},"required":["enabled"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScraperSourceSummary"}}},"description":"Updated scraper source"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Internal server error"}},"security":[{"bearerAuth":[]}],"summary":"Enable or disable a scraper source","tags":["Admin"],"x-internal":true}},"/admin/scraper/sources/{name}/diagnostics":{"get":{"description":"Returns the latest run, last successful run (for comparison when latest failed),\nand a configurable list of recent runs for the named scraper source.\n\nSmart default behavior:\n- Always includes the latest run\n- When the latest run failed, includes the last successful run for comparison\n- Returns empty response (200) if the source has no runs yet\n\nQuery parameters:\n- `limit`: Number of recent runs to return (1–100, default 10)\n","operationId":"getSourceDiagnostics","parameters":[{"description":"Scraper source name","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Number of recent runs to return (1–100, default 10)","in":"query","name":"limit","schema":{"default":10,"maximum":100,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SourceDiagnostics"}}},"description":"Source diagnostics with latest run, last success, and recent runs"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Internal server error"}},"security":[{"bearerAuth":[]}],"summary":"Get diagnostics for a scraper source","tags":["Admin"],"x-internal":true}},"/admin/scraper/sources/{name}/runs":{"get":{"description":"Returns the most recent runs for the named scraper source (up to 50).\n","operationId":"listScraperSourceRuns","parameters":[{"description":"Scraper source name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ScraperRunDetail"},"type":"array"}},"type":"object"}}},"description":"List of scraper runs"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Internal server error"}},"security":[{"bearerAuth":[]}],"summary":"List run history for a scraper source","tags":["Admin"],"x-internal":true}},"/admin/scraper/sources/{name}/trigger":{"post":{"description":"Dispatches a background scrape for the named source and returns 202\nimmediately. Returns 503 if this node has no scraper configured, and 404\nif the source name is unknown.\n","operationId":"triggerScraperSource","parameters":[{"description":"Scraper source name","in":"path","name":"name","required":true,"schema":{"type":"string"}}],"responses":{"202":{"content":{"application/json":{"schema":{"properties":{"source_name":{"type":"string"},"status":{"example":"triggered","type":"string"}},"type":"object"}}},"description":"Scrape triggered"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Internal server error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Scraper not configured on this node"}},"security":[{"bearerAuth":[]}],"summary":"Trigger an immediate scrape for a source","tags":["Admin"],"x-internal":true}},"/admin/scraper/submissions":{"get":{"description":"Returns a paginated list of URL submissions. Optionally filter by status.\nUseful for the admin review queue.\n","operationId":"listScraperSubmissions","parameters":[{"description":"Filter by submission status. One of: `pending_validation`, `pending`,\n`processed`, `rejected`.\n","in":"query","name":"status","schema":{"enum":["pending_validation","pending","processed","rejected"],"type":"string"}},{"description":"Maximum number of results (1–200, default 50).","in":"query","name":"limit","schema":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},{"description":"Number of results to skip (default 0).","in":"query","name":"offset","schema":{"default":0,"minimum":0,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"submissions":{"items":{"$ref":"#/components/schemas/Submission"},"type":"array"},"total":{"description":"Total number of submissions matching the filter","type":"integer"}},"type":"object"}}},"description":"Paginated list of submissions"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Internal server error"}},"security":[{"bearerAuth":[]}],"summary":"List URL submissions","tags":["Admin"],"x-internal":true}},"/admin/scraper/submissions/{id}":{"patch":{"description":"Marks a submission as `processed` (accepted for scraping) or `rejected`,\noptionally adding admin notes. Only submissions with status `pending` may\nbe reviewed.\n","operationId":"updateScraperSubmission","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"int64","type":"integer"}}],"requestBody":{"content":{"application/json":{"example":{"notes":"Good source — add to weekly scrape rotation","status":"processed"},"schema":{"properties":{"notes":{"description":"Optional admin notes","type":["string","null"]},"status":{"description":"New status for the submission","enum":["processed","rejected"],"type":"string"}},"required":["status"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Submission"}}},"description":"Updated submission"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Internal server error"}},"security":[{"bearerAuth":[]}],"summary":"Review a URL submission","tags":["Admin"],"x-internal":true}},"/admin/scraper/trigger-all":{"post":{"description":"Queues an orchestrator job that serially scrapes all enabled sources with\ndaily or weekly schedules. Returns immediately with 202 after queuing.\n\nSerial chaining timeout behavior is configurable via:\n- `SCRAPER_SOURCE_JOB_TIMEOUT_SECONDS` (default 300)\n- `SCRAPER_CHAIN_ENQUEUE_TIMEOUT_MS` (default 5000)\n- `SCRAPER_CHAIN_ENQUEUE_RETRIES` (default 3)\n\nOptions:\n- `respect_auto_scrape` (default: true): If true and global auto_scrape is disabled, skip the run\n- `skip_up_to_date` (default: true): Skip sources with fresh runs (daily \u003c24h, weekly \u003c7d)\n","operationId":"triggerAllScraperSources","requestBody":{"content":{"application/json":{"schema":{"properties":{"respect_auto_scrape":{"default":true,"type":"boolean"},"skip_up_to_date":{"default":true,"type":"boolean"}},"type":"object"}}},"required":false},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"respect_auto_scrape":{"type":"boolean"},"skip_up_to_date":{"type":"boolean"},"status":{"example":"skipped","type":"string"}},"type":"object"}}},"description":"Run skipped (auto_scrape disabled and respect_auto_scrape=true)"},"202":{"content":{"application/json":{"schema":{"properties":{"orchestrator_job_id":{"type":"integer"},"respect_auto_scrape":{"type":"boolean"},"skip_up_to_date":{"type":"boolean"},"status":{"example":"triggered","type":"string"}},"type":"object"}}},"description":"Orchestrator job queued"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"content":{"application/json":{"schema":{"properties":{"respect_auto_scrape":{"type":"boolean"},"running_sources":{"minimum":1,"type":"integer"},"skip_up_to_date":{"type":"boolean"},"status":{"example":"already_running","type":"string"}},"type":"object"}}},"description":"Run already in progress; overlapping run-all is blocked"},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Internal server error"},"503":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Job queue not available or orchestrator not configured.\nThis can occur if:\n- The River job queue is unavailable on this node\n- The scraper orchestrator dependencies are not wired (e.g., River not initialized)\nCheck that the server is configured with `RIVER_ENABLED=true` and the database\nschema includes River tables.\n"}},"security":[{"bearerAuth":[]}],"summary":"Trigger a serial scrape run for all eligible sources","tags":["Admin"],"x-internal":true}},"/api/v1/dev/accept-invitation":{"post":{"description":"Accept an invitation by token and set up developer account with password","operationId":"acceptDeveloperInvitation","requestBody":{"content":{"application/json":{"schema":{"properties":{"name":{"description":"Developer's full name","maxLength":100,"minLength":1,"type":"string"},"password":{"description":"Password for future logins (configurable via DEVELOPER_PASSWORD_MIN_LENGTH, default 8)","format":"password","minLength":8,"type":"string"},"token":{"description":"Invitation token from email","type":"string"}},"required":["token","name","password"],"type":"object"}}},"description":"Invitation acceptance details","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"developer":{"properties":{"email":{"type":"string"},"id":{"format":"uuid","type":"string"},"name":{"type":"string"}},"type":"object"},"token":{"description":"JWT token for immediate access","type":"string"}},"type":"object"}}},"description":"Invitation accepted, developer account created"},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Invalid token or weak password"},"410":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Invitation expired or already used"}},"security":[],"summary":"Accept developer invitation","tags":["Developer"]}},"/api/v1/dev/api-keys":{"get":{"description":"Retrieve all API keys belonging to the authenticated developer with usage summary","operationId":"listDeveloperApiKeys","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"items":{"items":{"$ref":"#/components/schemas/DeveloperAPIKey"},"type":"array"},"key_count":{"description":"Current number of keys","type":"integer"},"max_keys":{"description":"Maximum keys allowed for this developer","type":"integer"}},"type":"object"}}},"description":"List of API keys"},"401":{"$ref":"#/components/responses/Unauthorized"}},"security":[{"devCookie":[]},{"devJWT":[]}],"summary":"List own API keys","tags":["Developer"]},"post":{"description":"Create a new API key for the authenticated developer. Enforces max_keys limit.","operationId":"createDeveloperApiKey","requestBody":{"content":{"application/json":{"schema":{"properties":{"name":{"description":"Human-readable name for the key","maxLength":100,"minLength":1,"type":"string"}},"required":["name"],"type":"object"}}},"description":"API key details","required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeveloperAPIKeyCreated"}}},"description":"API key created"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Max keys exceeded"}},"security":[{"devCookie":[]},{"devJWT":[]}],"summary":"Create a new API key","tags":["Developer"]}},"/api/v1/dev/api-keys/{id}":{"delete":{"description":"Revoke an API key belonging to the authenticated developer","operationId":"revokeDeveloperApiKey","parameters":[{"description":"API key UUID","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"API key revoked"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Key not owned by developer"},"404":{"$ref":"#/components/responses/NotFound"}},"security":[{"devCookie":[]},{"devJWT":[]}],"summary":"Revoke own API key","tags":["Developer"]}},"/api/v1/dev/api-keys/{id}/usage":{"get":{"description":"Retrieve usage statistics for a specific API key with daily breakdown","operationId":"getDeveloperApiKeyUsage","parameters":[{"description":"API key UUID","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Start date for usage period (ISO 8601 date)","in":"query","name":"from","schema":{"format":"date","type":"string"}},{"description":"End date for usage period (ISO 8601 date)","in":"query","name":"to","schema":{"format":"date","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIKeyUsage"}}},"description":"API key usage statistics"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Key not owned by developer"},"404":{"$ref":"#/components/responses/NotFound"}},"security":[{"devCookie":[]},{"devJWT":[]}],"summary":"Get API key usage statistics","tags":["Developer"]}},"/api/v1/dev/login":{"post":{"description":"Authenticate developer with email/password and receive JWT token. Rate limited to 5 attempts per 15-minute window per IP (configurable via `RATE_LIMIT_LOGIN`, default 5).","operationId":"developerLogin","requestBody":{"content":{"application/json":{"schema":{"properties":{"email":{"format":"email","type":"string"},"password":{"format":"password","type":"string"}},"required":["email","password"],"type":"object"}}},"description":"Developer credentials","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"developer":{"properties":{"email":{"type":"string"},"github_username":{"type":"string"},"id":{"format":"uuid","type":"string"},"name":{"type":"string"}},"type":"object"},"expires_at":{"format":"date-time","type":"string"},"token":{"description":"JWT token for API access","type":"string"}},"type":"object"}}},"description":"Login successful"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Too many login attempts"}},"security":[],"summary":"Developer login","tags":["Developer"]}},"/api/v1/dev/logout":{"post":{"description":"Clear dev_auth_token cookie","operationId":"developerLogout","responses":{"204":{"description":"Logout successful"},"401":{"$ref":"#/components/responses/Unauthorized"}},"security":[{"devCookie":[]}],"summary":"Developer logout","tags":["Developer"]}},"/auth/github":{"get":{"description":"Redirect to GitHub OAuth authorization page with proper state parameter for CSRF protection","operationId":"githubOAuthStart","responses":{"302":{"description":"Redirect to GitHub OAuth","headers":{"Location":{"description":"GitHub OAuth authorization URL","schema":{"format":"uri","type":"string"}}}}},"security":[],"summary":"Initiate GitHub OAuth flow","tags":["OAuth"]}},"/auth/github/callback":{"get":{"description":"Handle GitHub OAuth callback, exchange code for token, and create/login developer","operationId":"githubOAuthCallback","parameters":[{"description":"Authorization code from GitHub","in":"query","name":"code","required":true,"schema":{"type":"string"}},{"description":"CSRF protection state parameter","in":"query","name":"state","required":true,"schema":{"type":"string"}}],"responses":{"302":{"description":"Redirect to developer dashboard on success","headers":{"Location":{"description":"Developer dashboard URL","schema":{"format":"uri","type":"string"}}}},"400":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Invalid state or GitHub error"}},"security":[],"summary":"GitHub OAuth callback","tags":["OAuth"]}},"/batch-status/{id}":{"get":{"description":"Retrieve the status and results of a batch event submission. Returns 404 if\nthe batch is still processing or doesn't exist.\n","operationId":"getBatchStatus","parameters":[{"description":"Batch ID returned from batch submission","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"@context":{"type":"string"},"@type":{"enum":["BatchSubmissionResult"],"type":"string"},"batch_id":{"type":"string"},"completed_at":{"format":"date-time","type":"string"},"created":{"description":"Number of events successfully created","type":"integer"},"duplicates":{"description":"Number of duplicate events detected","type":"integer"},"failed":{"description":"Number of events that failed validation/processing","type":"integer"},"results":{"items":{"properties":{"error":{"description":"Error message (if failed)","type":"string"},"event_id":{"description":"ULID of created/duplicate event (if successful)","type":"string"},"index":{"description":"Index of event in original batch array","type":"integer"},"status":{"enum":["created","failed","duplicate"],"type":"string"}},"type":"object"},"type":"array"},"status":{"enum":["completed"],"type":"string"},"total":{"description":"Total number of events processed","type":"integer"}},"type":"object"}}},"description":"Batch processing completed"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Batch not found or still processing"},"410":{"$ref":"#/components/responses/Gone"}},"security":[],"summary":"Get batch processing status","tags":["Events"]}},"/events":{"get":{"description":"Query events with optional filters. Returns paginated JSON-LD.","operationId":"listEvents","parameters":[{"$ref":"#/components/parameters/startDate"},{"$ref":"#/components/parameters/endDate"},{"$ref":"#/components/parameters/city"},{"$ref":"#/components/parameters/venueId"},{"$ref":"#/components/parameters/organizerId"},{"$ref":"#/components/parameters/lifecycleState"},{"$ref":"#/components/parameters/eventDomain"},{"$ref":"#/components/parameters/query"},{"$ref":"#/components/parameters/keywords"},{"$ref":"#/components/parameters/after"},{"$ref":"#/components/parameters/limit"}],"responses":{"200":{"content":{"application/ld+json":{"schema":{"$ref":"#/components/schemas/EventListResponse"}}},"description":"Paginated list of events"},"400":{"$ref":"#/components/responses/BadRequest"}},"security":[],"summary":"List events","tags":["Events"]},"post":{"description":"Submit a new event to the library. Requires agent or admin authentication.\nEvents are auto-published immediately and appear in admin review queue\nif flagged or low-confidence.\n","operationId":"createEvent","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventInput"}},"application/ld+json":{"schema":{"$ref":"#/components/schemas/EventInput"}}},"description":"Event data in JSON-LD or JSON format","required":true},"responses":{"201":{"content":{"application/ld+json":{"schema":{"$ref":"#/components/schemas/Event"}}},"description":"Event created successfully","headers":{"Location":{"description":"URI of the created event","schema":{"format":"uri","type":"string"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"content":{"application/ld+json":{"schema":{"$ref":"#/components/schemas/Event"}}},"description":"Duplicate event (idempotent - returns existing event)"}},"security":[{"apiKey":[]},{"bearerAuth":[]}],"summary":"Submit a new event","tags":["Events"]}},"/events/batch":{"post":{"description":"Submit multiple events for background processing. Returns immediately with a batch ID\nand job status URL. Use the batch status endpoint to check processing results.\n\nMaximum batch size: 100 events. Each event in the batch is processed independently,\nwith individual success/failure tracking.\n","operationId":"createEventBatch","requestBody":{"content":{"application/json":{"schema":{"properties":{"events":{"items":{"$ref":"#/components/schemas/EventInput"},"maxItems":100,"minItems":1,"type":"array"}},"required":["events"],"type":"object"}}},"description":"Array of events for batch processing (max 100 events)","required":true},"responses":{"202":{"content":{"application/json":{"schema":{"properties":{"@context":{"type":"string"},"@type":{"enum":["BatchSubmission"],"type":"string"},"batch_id":{"description":"Unique identifier for this batch submission","type":"string"},"job_id":{"description":"River job queue ID","type":"integer"},"status":{"enum":["processing"],"type":"string"},"status_url":{"description":"URL to check batch processing status","format":"uri","type":"string"},"submitted":{"description":"Number of events in the batch","type":"integer"}},"type":"object"}}},"description":"Batch accepted for processing"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"}},"security":[{"apiKey":[]},{"bearerAuth":[]}],"summary":"Submit multiple events in a batch","tags":["Events"]}},"/events/{id}":{"get":{"description":"Retrieve a single event by its ULID. Supports content negotiation.","operationId":"getEvent","parameters":[{"$ref":"#/components/parameters/eventId"}],"responses":{"200":{"content":{"application/ld+json":{"schema":{"$ref":"#/components/schemas/Event"}}},"description":"Event details"},"404":{"$ref":"#/components/responses/NotFound"}},"security":[],"summary":"Get event by ID","tags":["Events"]}},"/feeds/changes":{"get":{"description":"Ordered stream of changes for federation sync. Returns events in \nsequence order with action type and snapshot.\n","operationId":"getChangeFeed","parameters":[{"description":"Cursor from previous response (e.g., seq_1048576)","in":"query","name":"since","schema":{"type":"string"}},{"$ref":"#/components/parameters/limit"}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeFeedResponse"}}},"description":"Change feed"}},"security":[],"summary":"Get change feed","tags":["Feeds"]}},"/healthz":{"get":{"description":"Check if the service is alive and running. Used by orchestrators (Kubernetes, etc.) to determine if the service should be restarted.","operationId":"healthLiveness","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"status":{"example":"ok","type":"string"}},"type":"object"}}},"description":"Service is alive"}},"security":[],"summary":"Liveness probe","tags":["Health"]}},"/openapi.json":{"get":{"description":"Retrieve the OpenAPI 3.1 specification for this API in JSON format. Useful for API documentation and client generation.","operationId":"getOpenApiSpec","responses":{"200":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"OpenAPI 3.1 specification"}},"security":[],"summary":"OpenAPI specification","tags":["Health"]}},"/organizations":{"get":{"description":"Query organizations (event organizers) with optional filters. Returns paginated JSON-LD.","operationId":"listOrganizations","parameters":[{"$ref":"#/components/parameters/query"},{"$ref":"#/components/parameters/after"},{"$ref":"#/components/parameters/limit"}],"responses":{"200":{"content":{"application/ld+json":{"schema":{"$ref":"#/components/schemas/OrganizationListResponse"}}},"description":"Paginated list of organizations"}},"security":[],"summary":"List organizations","tags":["Organizations"]}},"/organizations/{id}":{"get":{"description":"Retrieve a single organization by its unique identifier. Returns JSON-LD.","operationId":"getOrganization","parameters":[{"description":"Unique identifier of the organization (ULID or URI)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/ld+json":{"schema":{"$ref":"#/components/schemas/Organization"}}},"description":"Organization details"},"404":{"$ref":"#/components/responses/NotFound"}},"security":[],"summary":"Get organization by ID","tags":["Organizations"]}},"/places":{"get":{"description":"Query places (venues) with optional filters. Returns paginated JSON-LD.","operationId":"listPlaces","parameters":[{"$ref":"#/components/parameters/city"},{"$ref":"#/components/parameters/query"},{"$ref":"#/components/parameters/after"},{"$ref":"#/components/parameters/limit"}],"responses":{"200":{"content":{"application/ld+json":{"schema":{"$ref":"#/components/schemas/PlaceListResponse"}}},"description":"Paginated list of places"}},"security":[],"summary":"List places","tags":["Places"]}},"/places/{id}":{"get":{"description":"Retrieve a single place (venue) by its unique identifier. Returns JSON-LD.","operationId":"getPlace","parameters":[{"description":"Unique identifier of the place (ULID or URI)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/ld+json":{"schema":{"$ref":"#/components/schemas/Place"}}},"description":"Place details"},"404":{"$ref":"#/components/responses/NotFound"}},"security":[],"summary":"Get place by ID","tags":["Places"]}},"/readyz":{"get":{"description":"Check if the service is ready to accept traffic. Verifies database connectivity and other dependencies.","operationId":"healthReadiness","responses":{"200":{"content":{"application/json":{"schema":{"properties":{"checks":{"properties":{"database":{"type":"string"}},"type":"object"},"status":{"example":"ready","type":"string"}},"type":"object"}}},"description":"Service is ready"},"503":{"description":"Service not ready"}},"security":[],"summary":"Readiness probe","tags":["Health"]}},"/scraper/submissions":{"post":{"description":"Submits up to 10 URLs for review and eventual scraping. No authentication\nrequired. Rate limited to 20 URLs per IP per 24 hours (configurable via\n`RATE_LIMIT_SUBMISSIONS_PER_IP_PER_24H`).\n\nEach URL is normalised and deduplicated before insertion. The response\nincludes a per-URL result indicating whether the URL was accepted,\nalready exists (duplicate), or rejected (invalid URL).\n","operationId":"submitURLs","requestBody":{"content":{"application/json":{"example":{"urls":["https://www.toronto.ca/events-calendar/"]},"schema":{"properties":{"urls":{"example":["https://www.toronto.ca/events-calendar/"],"items":{"format":"uri","type":"string"},"maxItems":10,"minItems":1,"type":"array"}},"required":["urls"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"results":{"items":{"$ref":"#/components/schemas/SubmissionResult"},"type":"array"}},"type":"object"}}},"description":"Submission results, one entry per submitted URL"},"400":{"$ref":"#/components/responses/BadRequest"},"429":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Rate limit exceeded — retry after 24 hours","headers":{"Retry-After":{"schema":{"example":86400,"type":"integer"}}}},"500":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ProblemDetails"}}},"description":"Internal server error"}},"security":[],"summary":"Submit URLs for scraping","tags":["Scraper"]}}},"servers":[{"description":"SEL Node API","url":"https://{node}/api/v1","variables":{"node":{"default":"toronto.togather.foundation","description":"SEL node domain"}}}],"tags":[{"description":"Administrative operations (requires admin auth)","name":"Admin"},{"description":"Developer self-service API key management","name":"Developer"},{"description":"Event discovery and submission","name":"Events"},{"description":"Change feeds for federation","name":"Feeds"},{"description":"Health check endpoints","name":"Health"},{"description":"GitHub OAuth authentication","name":"OAuth"},{"description":"Organizer information","name":"Organizations"},{"description":"Venue information","name":"Places"}]}