{"version":"v1","status":"ok","service":"Geeox REST API","paths":{"usage":"/usage","brands":"/brands","prompts":"/prompts","webhooks":"/webhooks","webhooks_item":"/webhooks/{webhookId}","webhooks_item_test":"/webhooks/{webhookId}/test","reports":"/reports","reports_item":"/reports/{reportId}","audit":"/audit","content_generate":"/content/generate","brand":"/brands/{brandId}","brand_prompts":"/brands/{brandId}/prompts","brand_suggested_prompts":"/brands/{brandId}/content/suggested-prompts","brand_visibility":"/brands/{brandId}/visibility","brand_visibility_history":"/brands/{brandId}/visibility/history","brand_results":"/brands/{brandId}/results","brand_citations":"/brands/{brandId}/citations","brand_competitors":"/brands/{brandId}/competitors","brand_competitors_sov_sentiment":"/brands/{brandId}/competitors/sov-sentiment","brand_ai_shelf":"/brands/{brandId}/ai-shelf"},"notes":["Send Authorization: Bearer <api_key> on protected routes.","This GET response includes Cache-Control (public, max-age=120, s-maxage=600, stale-while-revalidate=300), a weak ETag over the exact JSON bytes, and honors If-None-Match (304 when unchanged; If-None-Match: * is ignored on this GET). HEAD uses the same ETag, Cache-Control, and 304 rules with no body; 200 responses include Content-Length matching the UTF-8 byte size of the GET JSON. OPTIONS returns 204 with Allow: GET, HEAD, OPTIONS. Unsupported methods on /api/rest/v1 (POST, PUT, PATCH, DELETE) return 405 JSON { error, code: METHOD_NOT_ALLOWED, allow }. Discovery responses include CORS headers (Access-Control-Allow-Origin: *, Access-Control-Allow-Methods, Access-Control-Allow-Headers, Access-Control-Expose-Headers, Access-Control-Max-Age) and Vary: If-None-Match, Origin, Access-Control-Request-Method, Access-Control-Request-Headers. Node RestClient and browser GeeoxClient cache the last 200 by ETag and send If-None-Match on repeat getRestMeta() and headRestMeta(); getRestMeta({ forceRefresh: true }) skips conditional GET; headRestMeta({ forceRefresh: true }) skips conditional HEAD; clearRestMetaCache() drops that cache so the next getRestMeta() or headRestMeta() omits If-None-Match without a network call until you call getRestMeta again. Optional npm run example:rest env: GEEOX_REST_META_FORCE_REFRESH=1 smoke-tests forceRefresh; GEEOX_REST_META_CLEAR_CACHE=1 calls clearRestMetaCache() then getRestMeta() (lazy invalidation + full GET); GEEOX_REST_DISCOVERY_HEAD=1 calls headRestMeta() (authorized HEAD /api/rest/v1) and logs status, ETag, Content-Length; GEEOX_REST_DISCOVERY_HEAD_FORCE_REFRESH=1 calls headRestMeta({ forceRefresh: true }) for a full 200 HEAD smoke check. Details: docs/REST_API.md § Root (discovery).","GET /webhooks/{webhookId} returns one endpoint row (same JSON shape as list/create). PATCH body may include enabled (boolean), events (string[]), and/or url (string), at least one field required; same validation as POST for events and for url (HTTPS, SSRF guards). DELETE (204) on the same path. POST /webhooks/{webhookId}/test sends a synthetic webhook.test payload (empty body or JSON {} only; other bodies 400/415/413), GET on same path returns 405 Allow POST, updates last_delivery_* like a real delivery, and has a separate per-org rate limit (REST_WEBHOOK_TEST_RATE_LIMIT_*); collection GET/POST stays on /webhooks.","POST /content/generate error responses are JSON { error: string, code?: string }: 403 with code FORBIDDEN and reason usage_limit (org monthly prompt quota), plus pricing_url and billing_url (absolute HTTPS from NEXT_PUBLIC_APP_URL or default app host); 400 BAD_REQUEST (no LLM configured for GEO content or non-retryable provider error); 429 TOO_MANY_REQUESTS (all configured LLM providers hit quota/rate limits); 500 INTERNAL_SERVER_ERROR. Same 403 extras on POST /audit, POST /brands (brand cap), POST /prompts (library full). RestClient/GeeoxClient surface the same code on thrown errors. Details: docs/REST_API.md.","Many GET routes support CSV via ?format=csv or Accept: text/csv.","GET /usage JSON includes plan (effective quota tier), stored_plan (normalized organizations.plan), trial_ends_at (ISO or null for app-level trial); CSV columns plan, stored_plan, trial_ends_at, then usage fields.","POST /brands requires name and domain; optional industry, competitors, keywords, aliases, competitor_merge_aliases (same shapes as PATCH); unknown keys 400. 200 body matches GET /brands/{brandId}.","PATCH /brands/{brandId} accepts a partial JSON body (name, domain, industry, competitors, keywords, aliases, competitor_merge_aliases); unknown keys 400. Same row shape as GET (includes competitor_merge_aliases for GET …/competitors aggregation).","List pagination: GET /brands, /reports, /webhooks, /brands/{id}/prompts use optional limit + offset; JSON adds hasMore when limit is set (extra-row probe). GET /webhooks also accepts enabled=true|false. GET /brands/{id}/results always returns limit/offset/hasMore (default limit 100). GET /brands/{id}/citations paginates sources when limit is set. GET /brands/{id}/competitors optionally pages the aggregated competitor list (1–200). GET /brands/{id}/competitors/sov-sentiment returns points, totalMentionEvents, sovDistribution (brand + top rivals + optional Other bucket), and llmsInSample; optional query llm scopes to one model, maxCompetitors 1–15 (default 5); CSV via ?format=csv or Accept: text/csv (distribution rows plus sentiment when known). GET /brands/{id}/ai-shelf and GET /brands/{id}/content/suggested-prompts support limit + offset on returned items (defaults 50 and 10). GET /brands/{id}/ai-shelf: lookbackDays 1–365 (default 30) scopes prompt_results before aggregation.","Reports: POST /reports queues an export; GET /reports/{reportId} returns status and file_url when ready.","POST /audit accepts optional Idempotency-Key header for safe retries (see docs).","GET /brands/{brandId}/visibility JSON includes brand.competitor_merge_aliases (same as GET/PATCH /brands/{brandId}), geo_readiness (setup checklist score + checks), and visibility.factors / source_citation_rate / citation_sample_size / sentiment_avg when the latest score row exists. Optional ?include=per_llm adds per_llm[] plus dataset_source_citation (aggregate on latest 200 prompt_results). Optional include=dataset_citation alone fetches only sources_cited for that aggregate (lighter). Comma-separated include tokens; ignored for CSV export.","GET /brands/{brandId}/visibility/history?range=7d|30d returns time-ordered visibility_score_history points (score, mention_rate, avg_position, source_citation_rate, citation_sample_size, computed_at) for integrations and charts; CSV via ?format=csv or Accept: text/csv.","visibilityScoreModel on this discovery document lists the default factor weights (sum 100) and expectedLlmCount used to normalize LLM coverage; full formula and edge cases live in src/lib/scoring/visibility.ts.","geoCore states measurementModel live_llm_prompt_runs, defaultAuditLlmIds for this deployment (AUDIT_LLMS or product default), catalogLlmIds (all connectors the app knows), positioningNote, and capabilityTags for integrators comparing GEO platforms."],"visibilityScoreModel":{"weights":{"mentionRate":35,"position":28,"llmCoverage":17,"sentiment":10,"citationPresence":10},"expectedLlmCount":2,"implementation":"src/lib/scoring/visibility.ts"},"geoCore":{"measurementModel":"live_llm_prompt_runs","positioningNote":"Visibility and citations are derived from stored multi-LLM audit runs (real provider APIs), not third-party AI search rank estimates.","defaultAuditLlmIds":["llama","mistral"],"catalogLlmIds":["chatgpt","perplexity","gemini","copilot","claude","llama","mistral"],"capabilityTags":["multi_llm_prompt_runs","per_llm_visibility_breakdown","source_citation_analytics","competitor_share_of_voice","visibility_score_history","prompt_library_and_imports","ai_shelf_extraction","geo_aeo_content_generation","rest_csv_exports","outbound_webhooks"]},"usageLimitForbidden":{"type":"object","description":"Returned with HTTP 403 from POST /audit (prompt quota / free plan), POST /brands (brand cap), POST /prompts (library full), POST /content/generate (monthly prompt quota) when the workspace hits plan limits.","properties":{"error":{"type":"string","description":"Human-readable message."},"code":{"type":"string","enum":["FORBIDDEN"]},"reason":{"type":"string","enum":["usage_limit"]},"pricing_url":{"type":"string","description":"Absolute URL to marketing /pricing."},"billing_url":{"type":"string","description":"Absolute URL to in-app billing (plan picker)."}},"required":["error","code","reason","pricing_url","billing_url"],"additionalProperties":true},"outboundWebhooks":{"jsonSchemaDraft":"http://json-schema.org/draft-07/schema#","envelope":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","additionalProperties":false,"required":["event","payload","at","deliveryId"],"properties":{"event":{"type":"string","enum":["audit.completed","visibility.score_updated","webhook.test"],"description":"Synthetic webhook.test is only sent from POST /webhooks/{id}/test, not from subscriptions."},"payload":{"type":"object","description":"Shape depends on event; see outboundWebhooks.payloads in discovery."},"at":{"type":"string","format":"date-time","description":"ISO-8601 instant; matches last_delivery_at on success."},"deliveryId":{"type":"string","format":"uuid","description":"Stable per endpoint per logical send (retries reuse)."}}},"payloads":{"audit.completed":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","additionalProperties":false,"required":["brandId","promptRunId"],"properties":{"brandId":{"type":"string","format":"uuid"},"promptRunId":{"type":"string","format":"uuid"}}},"visibility.score_updated":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","additionalProperties":false,"required":["brandId","score","mention_rate","avg_position","source_citation_rate","citation_sample_size","dataset_source_citation"],"properties":{"brandId":{"type":"string","format":"uuid"},"score":{"type":"number"},"mention_rate":{"type":["number","null"]},"avg_position":{"type":["number","null"]},"source_citation_rate":{"type":["number","null"]},"citation_sample_size":{"oneOf":[{"type":"null"},{"type":"integer","minimum":0}],"description":"Null when neutral / no sourced data for score factors."},"dataset_source_citation":{"type":"object","additionalProperties":false,"required":["rate","sample_size","result_count"],"properties":{"rate":{"type":["number","null"],"description":"Null when brand domain is unset or sample is neutral."},"sample_size":{"type":"integer","minimum":0,"description":"Rows with ≥1 source URL in the window."},"result_count":{"type":"integer","minimum":0,"description":"Number of prompt_results in the window (up to 200 newest by run_at)."}}}}}},"syntheticTestPayload":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","additionalProperties":false,"required":["test"],"properties":{"test":{"const":true}}}}}