multica-ai/multica · error

workspace_id or workspace_slug required

Error message

workspace_id or workspace_slug required

What it means

HTTP 400 from the realtime WebSocket upgrade handler when the request carries neither workspace_id nor workspace_slug query parameters. The hub is strictly per-workspace — every socket is bound to one workspace's event stream — so it refuses to upgrade a connection that does not name one. resolveSlug being nil also leaves the slug path inert, falling through to this 400.

Source

Thrown at server/internal/realtime/hub.go:781

	conn.Close()
}

// HandleWebSocket upgrades an HTTP connection to WebSocket with cookie or
// first-message auth.
func HandleWebSocket(hub *Hub, mc MembershipChecker, pr PATResolver, resolveSlug SlugResolver, w http.ResponseWriter, r *http.Request) {
	workspaceID := r.URL.Query().Get("workspace_id")
	if workspaceID == "" {
		if slug := r.URL.Query().Get("workspace_slug"); slug != "" && resolveSlug != nil {
			resolved, err := resolveSlug(r.Context(), slug)
			if err != nil {
				http.Error(w, `{"error":"workspace not found"}`, http.StatusNotFound)
				return
			}
			workspaceID = resolved
		}
	}
	if workspaceID == "" {
		http.Error(w, `{"error":"workspace_id or workspace_slug required"}`, http.StatusBadRequest)
		return
	}

	var userID string
	if cookie, err := r.Cookie(auth.AuthCookieName); err == nil && cookie.Value != "" {
		uid, errMsg := authenticateToken(cookie.Value, pr, r.Context())
		if errMsg != "" {
			http.Error(w, errMsg, http.StatusUnauthorized)
			return
		}
		if !mc.IsMember(r.Context(), uid, workspaceID) {
			http.Error(w, `{"error":"not a member of this workspace"}`, http.StatusForbidden)
			return
		}
		userID = uid
	}

	conn, err := upgrader.Upgrade(w, r, nil)

View on GitHub (pinned to 2c0912b6ec)

Solutions

  1. Append ?workspace_id=<uuid> (or ?workspace_slug=<slug>) to the WebSocket URL.
  2. If using a slug, confirm the server wires a slug resolver; otherwise resolve the ID client-side first.
  3. Check for typos/casing in the parameter name.

Example fix

// before
const sock = new WebSocket(`ws://${host}/ws`)

// after
const sock = new WebSocket(`ws://${host}/ws?workspace_id=${workspaceId}`)
Defensive patterns

Strategy: validation

Validate before calling

function wsUrl(host: string, workspaceId: string): string {
  if (!workspaceId) throw new Error('workspaceId required for websocket')
  return `ws://${host}/ws?workspace_id=${encodeURIComponent(workspaceId)}`
}

Type guard

function canConnect(workspaceId?: string, slug?: string): boolean {
  return Boolean(workspaceId && workspaceId.trim()) || Boolean(slug && slug.trim())
}

Try / catch

conn, resp, err := dialer.Dial(url, nil)
if err != nil && resp != nil && resp.StatusCode == 400 {
    // missing workspace param: fix URL construction, do not retry as-is
}

Prevention

When it happens

Trigger: Opening ws://host/ws with no query params; passing the workspace in a header or body (ignored); a slug supplied while resolveSlug is nil (server wiring without slug support); parameter typo like workspaceid.

Common situations: Frontend builds the socket URL without appending the query string; migration from a global socket API to per-workspace sockets; copy-paste from old client code.

Related errors


AI-assisted analysis of multica-ai/multica@2c0912b6ec (2026-08-15). Data as JSON: /api/errors/07e37e79c52fbcad. Report an issue: GitHub.