diff --git a/docs/examples/substance_v4.md b/docs/examples/substance_v4.md
new file mode 100644
index 000000000..f453dedec
--- /dev/null
+++ b/docs/examples/substance_v4.md
@@ -0,0 +1,100 @@
+# Substances V4
+
+The `substances_v4` collection wraps the Albert v4 substance API, which exposes hazard data, custom tenant metadata, and structural identifiers at the CAS level.
+
+## Update metadata
+
+Use `update_metadata` to change specific fields on a tenant substance. Only the keyword arguments you pass are updated — everything else on the substance is left as-is.
+
+!!! example "Update scalar fields"
+    ```python
+    from albert import Albert
+
+    client = Albert.from_client_credentials()
+
+    client.substances_v4.update_metadata(
+        id="SUB123",
+        notes="Revised safety notes",
+        description="Aqueous solvent",
+        cas_smiles="O",
+    )
+    ```
+
+!!! example "Update a custom string metadata field"
+    ```python
+    from albert import Albert
+
+    client = Albert.from_client_credentials()
+
+    client.substances_v4.update_metadata(
+        id="SUB123",
+        metadata={"solubility": "5 mg/mL"},
+    )
+    ```
+
+!!! example "Update a single-select custom metadata field"
+    Single-select fields take an `EntityLink` whose `id` is the list item ID. Use `client.lists.get_all()` or `client.lists.get_matching_item()` to look up IDs.
+
+    ```python
+    from albert import Albert
+    from albert.resources.base import EntityLink
+
+    client = Albert.from_client_credentials()
+
+    client.substances_v4.update_metadata(
+        id="SUB123",
+        metadata={"cmr_eu": EntityLink(id="LST1253")},
+    )
+    ```
+
+!!! example "Update a multi-select custom metadata field"
+    Multi-select fields take a list of `EntityLink` objects representing the desired selection.
+
+    ```python
+    from albert import Albert
+    from albert.resources.base import EntityLink
+
+    client = Albert.from_client_credentials()
+
+    client.substances_v4.update_metadata(
+        id="SUB123",
+        metadata={
+            "amide_category": [
+                EntityLink(id="LST1256"),
+                EntityLink(id="LST1257"),
+            ]
+        },
+    )
+    ```
+
+!!! example "Delete a custom metadata field"
+    Pass `None` as the value to remove a custom field (works for string, single-select, and multi-select fields).
+
+    ```python
+    from albert import Albert
+
+    client = Albert.from_client_credentials()
+
+    client.substances_v4.update_metadata(
+        id="SUB123",
+        metadata={"deprecated_field": None},
+    )
+    ```
+
+!!! example "Mix scalar and custom field updates in one call"
+    ```python
+    from albert import Albert
+    from albert.resources.base import EntityLink
+
+    client = Albert.from_client_credentials()
+
+    client.substances_v4.update_metadata(
+        id="SUB123",
+        notes="Updated notes",
+        metadata={
+            "solubility": "10 mg/mL",
+            "cmr_eu": EntityLink(id="LST1253"),
+            "old_field": None,          # deletes this custom field
+        },
+    )
+    ```
diff --git a/mkdocs.yml b/mkdocs.yml
index de8703642..667b5f68d 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -247,6 +247,7 @@ nav:
       - Parameter Groups: examples/parameter_groups.md
       - Property Data: examples/property_data.md
       - Projects: examples/projects.md
+      - Substances V4: examples/substance_v4.md
       - Tasks: examples/tasks.md
       - Teams: examples/teams.md
 
diff --git a/src/albert/collections/chat_sessions.py b/src/albert/collections/chat_sessions.py
index 32adfb5d9..8090763cb 100644
--- a/src/albert/collections/chat_sessions.py
+++ b/src/albert/collections/chat_sessions.py
@@ -2,27 +2,14 @@
 
 from collections.abc import AsyncIterator
 
-from pydantic import GetCoreSchemaHandler, validate_call
-from pydantic_core import core_schema
+from pydantic import validate_call
 
 from albert.core.async_session import AsyncAlbertSession
 from albert.core.pagination import AsyncAlbertPaginator
+from albert.core.shared.types import _UNSET, _UnsetType
 from albert.resources.chats import ChatSession
 
 
-class _UnsetType:
-    """Sentinel type for distinguishing unset parameters from explicit None."""
-
-    @classmethod
-    def __get_pydantic_core_schema__(
-        cls, _source_type: object, _handler: GetCoreSchemaHandler
-    ) -> core_schema.CoreSchema:
-        return core_schema.is_instance_schema(cls)
-
-
-_UNSET = _UnsetType()
-
-
 class ChatSessionCollection:
     """
     Async collection for managing chat sessions (🧪Beta).
diff --git a/src/albert/collections/substance_v4.py b/src/albert/collections/substance_v4.py
index 713dab7cf..b29e22cd3 100644
--- a/src/albert/collections/substance_v4.py
+++ b/src/albert/collections/substance_v4.py
@@ -8,6 +8,8 @@
 from albert.core.pagination import AlbertPaginator
 from albert.core.session import AlbertSession
 from albert.core.shared.enums import PaginationMode
+from albert.core.shared.types import _UNSET, MetadataItem, _UnsetType
+from albert.exceptions import AlbertHTTPError
 from albert.resources.substance_v4 import (
     SubstanceV4Create,
     SubstanceV4CreateResult,
@@ -91,8 +93,8 @@ class SubstanceV4Collection(BaseCollection):
         Searches substances by keyword or advanced filters.
     create(substance) -> SubstanceV4CreateResult
         Creates a new substance record.
-    update_metadata(id, current_metadata, updated_metadata) -> None
-        Updates metadata fields on a substance by diffing current vs updated state.
+    update_metadata(id, ...) -> None
+        Updates metadata fields on a substance.
     """
 
     _api_version = "v4"
@@ -316,32 +318,100 @@ def update_metadata(
         self,
         *,
         id: str,
-        current_metadata: SubstanceV4Metadata,
-        updated_metadata: SubstanceV4Metadata,
+        notes: str | _UnsetType = _UNSET,
+        description: str | _UnsetType = _UNSET,
+        cas_smiles: str | _UnsetType = _UNSET,
+        inchi_key: str | _UnsetType = _UNSET,
+        iupac_name: str | _UnsetType = _UNSET,
+        cactus_status: str | _UnsetType = _UNSET,
+        metadata: dict[str, MetadataItem | None] | _UnsetType = _UNSET,
     ) -> None:
         """Update metadata fields on a substance.
 
-        Diffs ``current_metadata`` against ``updated_metadata`` and sends only the
-        changed fields. Scalar fields support ``add`` and ``update``; custom tenant
-        metadata fields also support ``delete`` (set the key to ``None`` in
-        ``updated_metadata`` with a value in ``current_metadata``).
+        Only the keyword arguments you pass are updated — all others are left unchanged.
+        The current state is fetched automatically.
 
         Parameters
         ----------
         id : str
             The substance ID to update.
-        current_metadata : SubstanceV4Metadata
-            The current metadata state of the substance.
-        updated_metadata : SubstanceV4Metadata
-            The desired metadata state of the substance.
+        notes : str, optional
+            Free-text notes.
+        description : str, optional
+            Substance description.
+        cas_smiles : str, optional
+            SMILES notation for the structure.
+        inchi_key : str, optional
+            InChIKey identifier.
+        iupac_name : str, optional
+            IUPAC name.
+        cactus_status : str, optional
+            CACTUS resolver status.
+        metadata : dict[str, MetadataItem | None], optional
+            Custom tenant metadata fields to update. Only the keys listed in this dict
+            are touched; all other custom fields on the substance are left unchanged.
+
+            Value types by field kind:
+
+            - **String / number fields** — pass the value directly (``"5 mg/mL"``, ``42``).
+            - **Single-select fields** — pass an ``EntityLink``; use
+              ``client.lists.get_matching_item()`` to look up the ID.
+            - **Multi-select fields** — pass a list of ``EntityLink`` objects; only the
+              changed items are sent.
+            - **Delete a field** — pass ``None`` as the value (works for all field types).
 
         Notes
         -----
         The following fields can be updated: ``notes``, ``description``, ``cas_smiles``,
         ``inchi_key``, ``iupac_name``, ``cactus_status``, and any custom metadata fields
         configured for the tenant.
+
+        Examples
+        --------
+        Update a scalar field and a custom string field:
+
+            client.substances_v4.update_metadata(
+                id="SUB123",
+                notes="new notes",
+                metadata={"solubility": "5 mg/mL"},
+            )
+
+        Set a single-select custom field:
+
+            client.substances_v4.update_metadata(
+                id="SUB123",
+                metadata={"cmr_eu": EntityLink(id="LST1253")},
+            )
+
+        Update a multi-select custom field (becomes exactly this set):
+
+            client.substances_v4.update_metadata(
+                id="SUB123",
+                metadata={"amide_category": [EntityLink(id="LST1256"), EntityLink(id="LST1257")]},
+            )
+
+        Delete a custom field:
+
+            client.substances_v4.update_metadata(id="SUB123", metadata={"old_key": None})
         """
+        scalar_kwargs = {
+            "notes": notes,
+            "description": description,
+            "cas_smiles": cas_smiles,
+            "inchi_key": inchi_key,
+            "iupac_name": iupac_name,
+            "cactus_status": cactus_status,
+        }
+        if all(v is _UNSET for v in scalar_kwargs.values()) and metadata is _UNSET:
+            return
+
         sub_id = id if id.startswith("SUB") else f"SUB{id}"
+        try:
+            substance = self.get_by_id(sub_id=sub_id, catch_errors=True)
+        except AlbertHTTPError:
+            # Substance exists but can't be fetched (e.g. no hazard data yet).
+            # Treat the current state as empty so all operations become adds.
+            substance = None
         operations = []
 
         for attr, wire_name in [
@@ -352,13 +422,15 @@ def update_metadata(
             ("iupac_name", "iUpacName"),
             ("cactus_status", "cactusStatus"),
         ]:
-            old = getattr(current_metadata, attr)
-            new = getattr(updated_metadata, attr)
+            new = scalar_kwargs[attr]
+            if new is _UNSET:
+                continue
+            old = getattr(substance, attr, None) if substance is not None else None
             if old == new:
                 continue
-            if old is None and new is not None:
+            if old is None:
                 operations.append({"operation": "add", "attribute": wire_name, "newValue": new})
-            elif old is not None and new is not None:
+            else:
                 operations.append(
                     {
                         "operation": "update",
@@ -367,15 +439,23 @@ def update_metadata(
                         "newValue": new,
                     }
                 )
-            # spec does not support delete for scalar fields — skip old→None case
 
-        metadata_patches = self._generate_metadata_diff(
-            existing_metadata=current_metadata.metadata or {},
-            updated_metadata=updated_metadata.metadata or {},
-        )
-        operations.extend(
-            p.model_dump(by_alias=True, mode="json", exclude_none=True) for p in metadata_patches
-        )
+        if metadata is not _UNSET and metadata:
+            # Coerce raw JSON dicts to EntityLink objects so _generate_metadata_diff
+            # can call .id on single/multi-select values.
+            raw_meta = substance.metadata if substance is not None else {}
+            coerced = SubstanceV4Metadata.model_validate({"metadata": raw_meta or {}})
+            current_meta = coerced.metadata or {}
+            relevant_existing = {k: v for k, v in current_meta.items() if k in metadata}
+            non_null_updates = {k: v for k, v in metadata.items() if v is not None}
+            metadata_patches = self._generate_metadata_diff(
+                existing_metadata=relevant_existing,
+                updated_metadata=non_null_updates,
+            )
+            operations.extend(
+                p.model_dump(by_alias=True, mode="json", exclude_none=True)
+                for p in metadata_patches
+            )
 
         if not operations:
             return
diff --git a/src/albert/core/shared/types.py b/src/albert/core/shared/types.py
index c229a7854..8b09b9293 100644
--- a/src/albert/core/shared/types.py
+++ b/src/albert/core/shared/types.py
@@ -1,6 +1,7 @@
 from typing import Annotated, TypeVar
 
-from pydantic import PlainSerializer
+from pydantic import GetCoreSchemaHandler, PlainSerializer
+from pydantic_core import core_schema
 
 from albert.core.shared.models.base import BaseResource, EntityLink, EntityLinkWithName
 
@@ -8,6 +9,19 @@
 MetadataItem = float | int | str | EntityLink | list[EntityLink]
 
 
+class _UnsetType:
+    """Sentinel type for distinguishing unset parameters from explicit ``None``."""
+
+    @classmethod
+    def __get_pydantic_core_schema__(
+        cls, _source_type: object, _handler: GetCoreSchemaHandler
+    ) -> core_schema.CoreSchema:
+        return core_schema.is_instance_schema(cls)
+
+
+_UNSET = _UnsetType()
+
+
 def convert_to_entity_link(value: BaseResource | EntityLink) -> EntityLink:
     if isinstance(value, BaseResource):
         return value.to_entity_link()
diff --git a/tests/collections/test_substance_v4.py b/tests/collections/test_substance_v4.py
index 5712e665e..366d0b2c5 100644
--- a/tests/collections/test_substance_v4.py
+++ b/tests/collections/test_substance_v4.py
@@ -9,7 +9,6 @@
     SubstanceV4Create,
     SubstanceV4Identifier,
     SubstanceV4Info,
-    SubstanceV4Metadata,
 )
 
 CAS_IDS = [
@@ -83,15 +82,11 @@ def test_update_metadata(client: Albert, static_custom_fields: list[CustomField]
     sub_id = result.created_items[0].substance_id
     assert sub_id
 
-    # All fields start as None → all operations are "add"
     client.substances_v4.update_metadata(
         id=sub_id,
-        current_metadata=SubstanceV4Metadata(),
-        updated_metadata=SubstanceV4Metadata(
-            notes="sdk test note",
-            cas_smiles="CCO",
-            metadata={substance_string_field.name: "sdk test value"},
-        ),
+        notes="sdk test note",
+        cas_smiles="CCO",
+        metadata={substance_string_field.name: "sdk test value"},
     )
 
 
