> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gominerva.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Profile Custom Fields Guide

> How administrators define organization-wide profile fields and how teams use them in profiles, lists, and batch uploads.

export const StorybookFrame = ({storyId, title, version = "20260419-1", height, zoom = 0.7, preferLocalPreview = false, manageStoryFocus = false, nonInteractivePreview = false}) => {
  const LOAD_TIMEOUT_MS = 15000;
  const POST_LOAD_STEAL_MS = 3000;
  const GESTURE_GRACE_MS = 1000;
  const STORYBOOK_HOST = "minerva-storybook.s3";
  const HOSTED_STORYBOOK_BASE_URL = "https://minerva-storybook.s3.ca-central-1.amazonaws.com/iframe.html";
  const isLocalDocsPreview = typeof window !== "undefined" && ["localhost", "127.0.0.1"].includes(window.location.hostname);
  const storybookBaseUrl = preferLocalPreview && isLocalDocsPreview ? "http://localhost:6006/iframe.html" : HOSTED_STORYBOOK_BASE_URL;
  const params = new URLSearchParams({
    id: storyId,
    viewMode: "story",
    singleStory: "true",
    shortcuts: "false",
    toolbar: "0",
    nav: "0",
    panel: "0"
  });
  params.set("v", version);
  const src = `${storybookBaseUrl}?${params.toString()}`;
  const unscaledHeight = `${Math.ceil(height / zoom)}px`;
  const unscaledWidth = `${100 / zoom}%`;
  const containerRef = useRef(null);
  const [failed, setFailed] = useState(false);
  useEffect(() => {
    if (failed) return undefined;
    const container = containerRef.current;
    if (!container || typeof document === "undefined") return undefined;
    let detach;
    const attach = iframe => {
      let loadCount = 0;
      let loadTimeoutId;
      const onLoad = () => {
        loadCount += 1;
        clearTimeout(loadTimeoutId);
        if (loadCount > 1) setFailed(true);
      };
      const startLoadTimeout = () => {
        if (loadTimeoutId || loadCount > 0) return;
        loadTimeoutId = setTimeout(() => {
          if (loadCount === 0) setFailed(true);
        }, LOAD_TIMEOUT_MS);
      };
      iframe.addEventListener("load", onLoad);
      let observer;
      if (typeof IntersectionObserver === "function") {
        observer = new IntersectionObserver(entries => {
          if (entries.some(entry => entry.isIntersecting)) startLoadTimeout();
        }, {
          rootMargin: "200px"
        });
        observer.observe(container);
      } else {
        startLoadTimeout();
      }
      return () => {
        iframe.removeEventListener("load", onLoad);
        clearTimeout(loadTimeoutId);
        if (observer) observer.disconnect();
      };
    };
    const tryAttach = () => {
      if (detach) return;
      const iframe = container.querySelector("iframe");
      if (iframe) detach = attach(iframe);
    };
    tryAttach();
    const mutations = new MutationObserver(tryAttach);
    mutations.observe(container, {
      childList: true,
      subtree: true
    });
    return () => {
      mutations.disconnect();
      if (detach) detach();
    };
  }, [failed]);
  useEffect(() => {
    if (!manageStoryFocus || typeof document === "undefined") return undefined;
    let animationFrameId;
    let guardUntil = 0;
    let lastGestureAt = 0;
    let previousActive = null;
    let stableScrollX = window.scrollX;
    let stableScrollY = window.scrollY;
    const frameLoadedAt = new WeakMap();
    const isStorybookFrame = element => element && element.tagName === "IFRAME" && (element.src || "").includes(STORYBOOK_HOST);
    const noteGesture = event => {
      if (event.key === "Tab") lastGestureAt = Date.now();
    };
    const checkFocus = () => {
      animationFrameId = undefined;
      if (document.visibilityState === "visible") {
        const active = document.activeElement;
        if (active === previousActive) {
          if (!active || active.tagName !== "IFRAME") {
            stableScrollX = window.scrollX;
            stableScrollY = window.scrollY;
          }
        } else {
          previousActive = active;
          if (!active || active.tagName !== "IFRAME") {
            stableScrollX = window.scrollX;
            stableScrollY = window.scrollY;
          } else if (isStorybookFrame(active)) {
            const loadedAt = frameLoadedAt.get(active);
            const deliberate = loadedAt && lastGestureAt > loadedAt && Date.now() - lastGestureAt < GESTURE_GRACE_MS;
            if (loadedAt && Date.now() - loadedAt <= POST_LOAD_STEAL_MS && !deliberate) {
              active.blur();
              window.scrollTo(stableScrollX, stableScrollY);
              previousActive = document.activeElement;
            }
          }
        }
      }
      if (Date.now() < guardUntil) animationFrameId = requestAnimationFrame(checkFocus);
    };
    const noteFrameLoad = event => {
      const target = event.target;
      if (!isStorybookFrame(target)) return;
      const loadedAt = Date.now();
      frameLoadedAt.set(target, loadedAt);
      guardUntil = Math.max(guardUntil, loadedAt + POST_LOAD_STEAL_MS);
      if (!animationFrameId) animationFrameId = requestAnimationFrame(checkFocus);
    };
    document.addEventListener("keydown", noteGesture, true);
    document.addEventListener("load", noteFrameLoad, true);
    return () => {
      document.removeEventListener("keydown", noteGesture, true);
      document.removeEventListener("load", noteFrameLoad, true);
      if (animationFrameId) cancelAnimationFrame(animationFrameId);
    };
  }, [manageStoryFocus]);
  const frameStyle = {
    height: `${height}px`,
    overflow: "hidden",
    border: "1px solid #e2e8f0",
    borderRadius: "12px",
    backgroundColor: "#ffffff",
    display: "flex",
    justifyContent: "center",
    alignItems: "flex-start"
  };
  if (failed) {
    return <div style={{
      width: "100%"
    }}>
        <div role="status" aria-live="polite" style={{
      ...frameStyle,
      alignItems: "flex-start",
      backgroundColor: "#f8fafc",
      color: "#475569",
      textAlign: "center",
      padding: "0 24px"
    }}>
          <div style={{
      width: "100%",
      height: "100%",
      maxHeight: "280px",
      display: "grid",
      placeItems: "center"
    }}>
            <div>
              <div role="heading" aria-level="4" style={{
      fontWeight: 600,
      margin: "0 0 4px"
    }}>
                {title}
              </div>
              <p style={{
      fontSize: "0.875rem",
      margin: 0
    }}>
                This figure is unavailable here. Continue with the instructions
                on this page.
              </p>
            </div>
          </div>
        </div>
      </div>;
  }
  return <div style={{
    width: "100%"
  }} ref={containerRef}>
      <div style={frameStyle}>
        <iframe src={src} tabIndex={nonInteractivePreview ? -1 : undefined} title={title} loading="lazy" style={{
    width: unscaledWidth,
    height: unscaledHeight,
    transform: `scale(${zoom})`,
    border: "0",
    display: "block",
    transformOrigin: "top center",
    pointerEvents: nonInteractivePreview ? "none" : "auto",
    flexShrink: 0
  }} allowFullScreen></iframe>
      </div>
      {nonInteractivePreview && <p style={{
    margin: "8px 0 0",
    textAlign: "right",
    fontSize: "0.875rem"
  }}>
          <a href={src} target="_blank" rel="noopener noreferrer">
            Open {title} in a new tab
          </a>
        </p>}
    </div>;
};

Profile custom fields let your organization store information that is specific to your operating model on Minerva profiles. Administrators define the fields once, and profiles can carry values across every workspace in your organization.

**Access:** Only administrators can configure definitions. In the sidebar, go to **Administration** > **Configuration** > **Profile Custom Fields**. Other users can view and use permitted fields on profiles and in the profiles list.

Use this guide when you need to:

* add organization-specific information such as Loan Number or Loan Status
* make selected fields available as profile-list columns, filters, and sort options
* prepare CSV or XLSX files that include custom profile data
* understand required fields, archival, history, and rollback
* connect an integration to profile custom fields

<Info>
  Definitions belong to your organization and apply across all its workspaces.
  Profile values still belong to their profiles. Changing workspaces does not
  create a second set of definitions.
</Info>

## Key concepts

| Concept        | What it means                                                                                                                                |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| **Definition** | The administrator-managed description of a custom field, including its label, type, required status, priority, and list capabilities.        |
| **Label**      | The customer-facing name shown in Minerva and recognized as a CSV or XLSX column header. Labels can be updated.                              |
| **Field key**  | The immutable identifier used by API integrations. For example, the label **Loan Status** might use the key `loan_status`.                   |
| **Priority**   | The order in which custom fields appear in profile details and generated templates.                                                          |
| **Required**   | A rule applied when future profiles are created or imported. Existing profiles are not changed automatically.                                |
| **Archived**   | A retired definition that cannot accept new values. Archived definitions and prior configuration changes remain available for audit context. |

<Warning>
  Use field keys, not labels, in API integrations. Labels can change, but keys
  remain fixed. Record each returned key in the system that sends profile data
  to Minerva.
</Warning>

## Configure profile custom fields

The configuration page lists active and archived definitions. It also shows which fields are required, available for filtering, and displayed in the profiles list.

<StorybookFrame storyId="docs-profile-custom-fields--administration" title="Profile Custom Fields Administration" height={610} zoom={0.7} version="20260827-profile-custom-fields" preferLocalPreview heightPolicy="fit-content" />

To create a field:

1. Select **Create custom field**.
2. Enter a clear label and, when useful, a description.
3. Confirm the generated field key. Choose a stable key before saving because it cannot be changed later.
4. Select the field type.
5. Leave the field optional unless every future profile must contain a value.
6. Choose whether supported fields can be filtered or shown as a list column.
7. Set the priority, review the change, and save it.

Choose labels that make sense to reviewers and upload operators. Use **Loan Number** rather than an internal project code. Descriptions should explain the expected value, not repeat the label.

<Info>
  After you edit or archive a definition, the change may take up to about one
  minute to appear in profile details, profile lists, and other read views. This
  delay does not change stored profile values. After the read view refreshes, a
  renamed label appears under its new name and an archived field is omitted.
  Profile creates, onboarding, and supplied values in updates validate against
  the latest definition. If Minerva cannot retrieve that definition, it rejects
  the write rather than saving an unvalidated value.
</Info>

### Supported types

| UI label            | Stored value          | Guidance                                                                                                                                             |
| ------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Text**            | Text string           | Up to 4,000 characters. Suitable for references, names, and short notes.                                                                             |
| **Number**          | Integer or decimal    | Accepts exact whole numbers through the signed 64-bit range and finite decimal values. Use a text field instead when leading zeroes are significant. |
| **Date**            | Date string           | Use `YYYY-MM-DD`, such as `2026-08-27`.                                                                                                              |
| **Yes/No**          | Boolean               | Stores `true` or `false`.                                                                                                                            |
| **Choice**          | One configured option | Reviewers see the option label. Integrations send the option's stored value.                                                                         |
| **Structured data** | JSON object or array  | Up to 16 KB for the field. It appears on profile details but cannot be a list column, filter, or sort field.                                         |

### Edit Choice options

After you save a Choice field, you can add options, change an option's display label, and reorder the options. The stored value for each saved option cannot be changed or removed in this release. Minerva hides or disables removal for saved options; you can still remove a newly added row before saving it.

Option values identify data already stored on profiles. Preserving those values keeps historical profile data readable when labels or ordering change. Choose durable stored values, and use display labels for wording that may evolve.

If the vocabulary must be replaced, contact [Minerva Support](mailto:support@gominerva.com). You can also archive the field and create a new one, but the new field starts empty and existing profile values are not moved to it.

All custom values on one profile have a combined 64 KB limit.

<Tip>
  Use **Text** for identifier-like values, even when they contain only digits.
  This preserves leading zeroes and avoids numeric limits in downstream tools.
</Tip>

### Required fields

Optional is the default and is safer during rollout. A required field affects future profile creation:

* API profile creation and onboarding requests are rejected when the value is missing or `null`.
* CSV and XLSX rows fail when the required column or row value is missing or invalid.
* Existing profiles and values remain unchanged.
* Unrelated profile updates are not rejected only because an older profile lacks the field.

<StorybookFrame storyId="docs-profile-custom-fields--required-field-warning" title="Required Field Warning" height={990} zoom={0.7} version="20260827-profile-custom-fields" preferLocalPreview heightPolicy="fit-content" />

<Warning>
  Coordinate with your technical team before making a field required. Update API
  integrations, onboarding flows, and upload processes first. A newly required
  definition is enforced on the next create or import after the change is
  active.
</Warning>

### List columns, filtering, and search

Administrators can mark up to five non-Structured data fields across the organization as filterable and searchable. A supported definition can also be marked for display in the profiles list.

Custom columns appear in the **Columns** picker after organization fields. Each user's column selection is saved as an individual preference. Filtering and sorting follow the selected field's type:

* Text supports exact and contains searches.
* Number and Date support exact values and ranges.
* Yes/No supports either value.
* Choice supports configured options.
* Structured data does not support columns, filtering, or sorting.

<StorybookFrame storyId="docs-profile-custom-fields--profile-list-columns" title="Custom Fields In The Profiles List" height={210} zoom={0.7} version="20260827-profile-custom-fields" preferLocalPreview heightPolicy="fit-content" />

## View profile values

When a profile has custom values, Minerva shows the most useful values as chips in the profile header. The complete set appears in the last **Profile details** tab, ordered by definition priority. **Potential matches** remains the default tab.

<StorybookFrame storyId="docs-profile-custom-fields--profile-details" title="Custom Fields In Profile Details" height={1010} zoom={0.7} version="20260827-profile-custom-fields" preferLocalPreview heightPolicy="fit-content" />

Choice fields show their readable option label. Structured data is formatted for review on the profile page. Fields with no stored value are not shown.

## Include fields in batch uploads

Download a current CSV or XLSX template before preparing a batch. Templates are generated from the active definitions and include the recognized custom field labels in priority order.

<StorybookFrame storyId="docs-profile-custom-fields--batch-upload" title="Profile Custom Fields In Batch Upload" height={775} zoom={0.7} version="20260827-profile-custom-fields" preferLocalPreview heightPolicy="fit-content" />

For each custom column:

* use the definition label as the header
* include every required column and a valid value in every imported row
* use a configured Choice option
* use `YYYY-MM-DD` for Date values
* use valid JSON objects or arrays for Structured data

An invalid required value fails that row. An invalid optional value produces a warning, skips only that custom value, and imports the rest of the profile. Review the validation report before confirming the upload.

## Connect an API integration

API integrations send an object keyed by immutable field keys and receive a labeled array in profile responses. See the [Profile Custom Fields API guide](/api-reference/profile-custom-fields) for request formats, response examples, validation rules, and compatibility guidance.

## Operating model

1. Define optional fields and confirm labels, types, keys, and Choice options with business and technical owners.
2. Treat saved Choice option values as immutable identifiers in this release. Use display labels for wording changes, and add options when the vocabulary expands.
3. Update API producers and batch templates using the active definitions.
4. Test profile creation, onboarding, updates, reads, filters, and uploads.
5. Make a field required only after every creation path supplies it.
6. Review the five-field filterable limit before enabling another field.
7. Review configuration history after each change.

## Archive, history, and rollback

Archive a definition when it should no longer accept new values. Archiving prevents new API writes through that key as soon as the change is active and removes the field from active configuration and generated templates. Stored values remain on profile records for history. An archived field may remain visible in profile read views for up to about one minute, then it is omitted after those views refresh.

Do not repurpose an old field by changing its label to a different meaning. Archive it and create a new definition with a new key. This keeps historical data understandable.

Configuration history records audited changes. Rollback replays the selected historical snapshot through the current configuration checks, so it does not always succeed. In particular, rollback cannot remove a saved Choice option value. A snapshot that predates a later-added option omits that saved value and is rejected with HTTP 409. Use a forward change that keeps every saved Choice value, such as relabeling, reordering, or adding options, or contact [Minerva Support](mailto:support@gominerva.com). A successful rollback does not reconstruct values that an integration cleared or replace an import file.

## Related guides

* [Profiles](/minerva-profiles)
* [Profile Groups Guide](/profile-groups-guide)
* [Workspaces Guide](/workspaces-guide)
* [Profile Custom Fields API guide](/api-reference/profile-custom-fields)
* [API Reference](/api-reference/introduction)
