diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,40 @@
 # Changelog for notion-client
 
+## 0.5.0.0 (2026-03-29)
+
+### Breaking Changes
+* Replace untyped `PropertyValue`/`PropertyItem`/`PropertyValueType` with a proper `PropertyValue` sum type (23 variants) in new `Notion.V1.PropertyValue` module
+* Remove `SelectOption` from `Notion.V1.Pages` — use `SelectOptionValue` from `Notion.V1.PropertyValue` instead
+* `PageObject.properties` is now `Map Text PropertyValue` instead of `Map Text PropertyItem`
+* `CreatePage.properties` and `UpdatePage.properties` are now `Map Text PropertyValue`
+* `UpdateDataSource.properties` changes from `Maybe (Map Text PropertySchema)` to `Maybe (Map Text (Maybe PropertySchema))` to support property deletion via `null`
+* `QueryDatabase` and `QueryDataSource` gain a `filterProperties` field
+* `UpdateDatabase` gains an `isLocked` field
+* `CreateDataSource` gains `description` and `cover` fields
+
+### New Features
+* **Typed Property Values**: New `Notion.V1.PropertyValue` module with `PropertyValue` sum type for reading page properties via pattern matching, and smart constructors (`titleValue`, `selectValue`, `numberValue`, `checkboxValue`, `dateValue`, `statusValue`, `relationValue`, `peopleValue`, `filesValue`, etc.) for writes
+* **Page Property Item Endpoint**: Add `retrievePageProperty` method (`GET /v1/pages/{page_id}/properties/{property_id}`) with `PropertyItemResponse` type handling both single and paginated results
+* **Typed Error Handling**: `NotionError` now has an `Exception` instance. The client automatically parses Notion API errors into typed `NotionError` exceptions. Add `parseNotionError` helper and `ToJSON` instance
+* **Property Schema Deletion**: Set properties to `Nothing` in `UpdateDataSource` to delete them from the schema (emits `null` in JSON)
+* **Auto-Pagination**: New `paginateAll` and `paginateCollect` functions in `Notion.V1.Pagination` that follow cursors automatically
+* Add `publicUrl :: Maybe Text` to `PageObject`
+* Mark `queryDatabase` as deprecated in favor of `queryDataSource`
+
+## 0.4.0.0 (2026-03-29)
+
+### Breaking Changes
+* Replace `properties :: Maybe Value` in `DatabaseObject` with `Maybe (Map Text PropertySchema)`
+* Replace `properties :: Value` in `DataSourceObject` with `Map Text PropertySchema`
+* Replace `properties :: Value` / `Maybe Value` in `CreateDataSource`, `UpdateDataSource`, `InitialDataSource` with typed `PropertySchema`
+* Replace `filter :: Maybe Value` in `QueryDatabase` and `QueryDataSource` with `Maybe Filter`
+* Replace `sorts :: Maybe [Value]` in `QueryDatabase` and `QueryDataSource` with `Maybe [Sort]`
+
+### New Features
+* **Typed Property Schemas**: New `Notion.V1.Properties` module with `PropertySchema` sum type (23 property types), `SelectColor` enum (10 colors), `NumberFormat` enum (39 formats), `RollupFunction` enum (25 functions), `RelationType`, `SelectOption`, `StatusGroup`
+* **Typed Query Filters**: New `Notion.V1.Filter` module with `Filter` DSL — compound `And`/`Or` filters, `PropertyFilter` with 22 condition variants (text, number, checkbox, select, multi-select, date, people, files, relation, status, unique ID, verification, formula, rollup), and `TimestampFilter`
+* **Typed Query Sorts**: `Sort` type with `PropertySort` and `TimestampSort`, `SortDirection` enum
+
 ## 0.3.1.0 (2026-03-29)
 
 ### New Features
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -43,22 +43,25 @@
     print page
 ```
 
-### Creating a page
+### Creating a page with typed properties
 
 ```haskell
 import Notion.V1
 import Notion.V1.Common (Parent(..))
 import Notion.V1.Pages
+import Notion.V1.PropertyValue qualified as PV
 import Data.Map qualified as Map
-import Data.Aeson (toJSON)
+import Data.Vector qualified as Vector
 
 createNewPage :: Methods -> IO PageObject
 createNewPage Methods{createPage} = do
     let pageProperties = Map.fromList
-            [ ("title", PropertyValue
-                { type_ = Title
-                , value = Just $ toJSON [-- rich text objects --]
-                })
+            [ ("title", PV.titleValue (Vector.singleton titleRichText))
+            , ("Status", PV.selectValue "In Progress")
+            , ("Priority", PV.selectValue "High")
+            , ("Due", PV.dateValue "2024-06-01" Nothing)
+            , ("Score", PV.numberValue 42)
+            , ("Done", PV.checkboxValue False)
             ]
 
         newPage = mkCreatePage
@@ -68,6 +71,18 @@
     createPage newPage
 ```
 
+### Reading typed properties
+
+```haskell
+import Notion.V1.PropertyValue
+
+readPageStatus :: PageObject -> Maybe Text
+readPageStatus page =
+    case Map.lookup "Status" (properties page) of
+        Just (SelectValue _ (Just opt)) -> Just (name opt)
+        _ -> Nothing
+```
+
 ### Creating a page with markdown
 
 ```haskell
@@ -104,6 +119,32 @@
                 ]
             , allowDeletingContent = Nothing
             }
+```
+
+### Error handling
+
+```haskell
+import Control.Exception (catch)
+import Notion.V1.Error (NotionError(..))
+
+safeRetrieve :: Methods -> PageID -> IO ()
+safeRetrieve Methods{retrievePage} pageId =
+    retrievePage pageId `catch` \(e :: NotionError) ->
+        putStrLn $ "Notion error: " <> code e <> " - " <> message e
+```
+
+### Auto-pagination
+
+```haskell
+import Notion.V1.Pagination (paginateAll)
+import Notion.V1.DataSources (QueryDataSource(..))
+
+allPages <- paginateAll $ \cursor ->
+    queryDataSource methods dsId QueryDataSource
+        { filter = Nothing, sorts = Nothing
+        , startCursor = cursor, pageSize = Just 100
+        , inTrash = Nothing, filterProperties = Nothing
+        }
 ```
 
 ## API Coverage
diff --git a/notion-client-example/DatabaseDemo.hs b/notion-client-example/DatabaseDemo.hs
--- a/notion-client-example/DatabaseDemo.hs
+++ b/notion-client-example/DatabaseDemo.hs
@@ -7,19 +7,24 @@
 
 import Blocks (createBulletedListItemBlock, createHeadingBlock, createParagraphBlock)
 import Console (printHeader, runTest)
-import Data.Aeson qualified as Aeson
-import Data.Map (fromList)
+import Control.Exception qualified as Exception
+import Data.Map qualified as Map
 import Data.String (fromString)
 import Data.Text qualified as Text
 import Data.Vector qualified as Vector
 import Notion.V1 (Methods (..))
 import Notion.V1.Blocks qualified as Blocks
 import Notion.V1.Comments (CommentObject (..), CreateComment (..))
-import Notion.V1.Common (Icon (..), Parent (..))
+import Notion.V1.Common (Icon (..), Parent (..), UUID (..))
 import Notion.V1.DataSources qualified as DataSources
 import Notion.V1.Databases (DataSource (..), DatabaseObject (..))
+import Notion.V1.Error (NotionError (..))
+import Notion.V1.Filter (Sort (..), SortDirection (..))
 import Notion.V1.ListOf (ListOf (..))
-import Notion.V1.Pages (CreatePage (..), PageObject (..), PropertyValue (..), PropertyValueType (Select, Title))
+import Notion.V1.Pages (CreatePage (..), PageObject (..), PropertyItemResponse (..))
+import Notion.V1.Pagination (paginateAll)
+import Notion.V1.Properties (PropertySchema (..), SelectColor (..), SelectOption (..))
+import Notion.V1.PropertyValue qualified as PV
 import Notion.V1.RichText (RichText (..), RichTextContent (..), TextContent (..), defaultAnnotations)
 import Prelude hiding (id)
 
@@ -59,13 +64,15 @@
   putStrLn $ "  properties: " <> show dsProperties
 
   -- Query the data source directly (preferred over queryDatabase in 2025-09-03)
+  -- Using typed sorts to order by created_time descending
   let dsQueryParams =
         DataSources.QueryDataSource
           { filter = Nothing,
-            sorts = Nothing,
+            sorts = Just [PropertySort "Name" Ascending],
             startCursor = Nothing,
             pageSize = Just 5,
-            inTrash = Nothing
+            inTrash = Nothing,
+            filterProperties = Nothing
           }
   dsResults <-
     runTest (Text.pack "Querying data source") $
@@ -77,19 +84,9 @@
   printHeader (Text.pack "Creating Data Source")
 
   let newDsProperties =
-        Aeson.object
-          [ ( "Name",
-              Aeson.object
-                [ ("type", Aeson.String "title"),
-                  ("title", Aeson.object [])
-                ]
-            ),
-            ( "Description",
-              Aeson.object
-                [ ("type", Aeson.String "rich_text"),
-                  ("rich_text", Aeson.object [])
-                ]
-            )
+        Map.fromList
+          [ ("Name", TitleSchema {schemaId = "", schemaName = "Name"}),
+            ("Description", RichTextSchema {schemaId = "", schemaName = "Description"})
           ]
 
       createDsRequest =
@@ -97,7 +94,9 @@
           { parent = DatabaseParent {databaseId = databaseId},
             properties = newDsProperties,
             title = Nothing,
-            icon = Nothing
+            description = Nothing,
+            icon = Nothing,
+            cover = Nothing
           }
 
   newDataSource <-
@@ -111,43 +110,22 @@
   -- Update data source schema: add properties
   printHeader (Text.pack "Updating Data Source Schema")
 
-  let -- Define Status and Priority select properties with options
+  let statusOptions =
+        Vector.fromList
+          [ SelectOption {id = Nothing, name = "Not Started", color = Just Red},
+            SelectOption {id = Nothing, name = "In Progress", color = Just Yellow},
+            SelectOption {id = Nothing, name = "Done", color = Just Green}
+          ]
+      priorityOptions =
+        Vector.fromList
+          [ SelectOption {id = Nothing, name = "High", color = Just Red},
+            SelectOption {id = Nothing, name = "Medium", color = Just Yellow},
+            SelectOption {id = Nothing, name = "Low", color = Just Gray}
+          ]
       combinedProperties =
-        Aeson.object
-          [ ( "Status",
-              Aeson.object
-                [ ("type", Aeson.String "select"),
-                  ( "select",
-                    Aeson.object
-                      [ ( "options",
-                          Aeson.Array $
-                            Vector.fromList
-                              [ Aeson.object [("name", Aeson.String "Not Started"), ("color", Aeson.String "red")],
-                                Aeson.object [("name", Aeson.String "In Progress"), ("color", Aeson.String "yellow")],
-                                Aeson.object [("name", Aeson.String "Done"), ("color", Aeson.String "green")]
-                              ]
-                        )
-                      ]
-                  )
-                ]
-            ),
-            ( "Priority",
-              Aeson.object
-                [ ("type", Aeson.String "select"),
-                  ( "select",
-                    Aeson.object
-                      [ ( "options",
-                          Aeson.Array $
-                            Vector.fromList
-                              [ Aeson.object [("name", Aeson.String "High"), ("color", Aeson.String "red")],
-                                Aeson.object [("name", Aeson.String "Medium"), ("color", Aeson.String "yellow")],
-                                Aeson.object [("name", Aeson.String "Low"), ("color", Aeson.String "gray")]
-                              ]
-                        )
-                      ]
-                  )
-                ]
-            )
+        Map.fromList
+          [ ("Status", Just (SelectSchema {schemaId = "", schemaName = "Status", selectOptions = statusOptions})),
+            ("Priority", Just (SelectSchema {schemaId = "", schemaName = "Priority", selectOptions = priorityOptions}))
           ]
 
       updateDsRequest =
@@ -167,53 +145,25 @@
   putStrLn "Data source updated with new properties"
 
   -- Create a new page in the database with initial content
-  let -- Step 1: Create title property (required for database pages)
-      -- The "title" key should match your database title field name
-      textObj = Aeson.object [("content", Aeson.String "Test Page from API")]
-      textItem = Aeson.object [("text", textObj)]
-      titleArray = Aeson.Array (Vector.singleton textItem)
-      titleProp = Aeson.object [("title", titleArray)]
-
-      -- Step 2: Create select property values for Status and Priority
-      -- Select properties need a "select" wrapper with a "name" field
-      statusProp =
-        Aeson.object
-          [ ( "select",
-              Aeson.object [("name", Aeson.String "In Progress")]
-            )
-          ]
-      priorityProp =
-        Aeson.object
-          [ ( "select",
-              Aeson.object [("name", Aeson.String "High")]
-            )
-          ]
+  let -- Create page properties using typed smart constructors
+      titleRichText =
+        Vector.singleton
+          RichText
+            { plainText = "Test Page from API",
+              href = Nothing,
+              annotations = defaultAnnotations,
+              type_ = "text",
+              content = TextContentWrapper (TextContent {content = "Test Page from API", link = Nothing})
+            }
 
-      -- Step 3: Create page properties map with all properties
-      -- Add the title and the new Status/Priority properties
       pageProperties =
-        fromList
-          [ ( "title", -- This must match your database's title field name
-              PropertyValue
-                { type_ = Title,
-                  value = Just titleProp
-                }
-            ),
-            ( "Status", -- Set the Status property
-              PropertyValue
-                { type_ = Select,
-                  value = Just statusProp
-                }
-            ),
-            ( "Priority", -- Set the Priority property
-              PropertyValue
-                { type_ = Select,
-                  value = Just priorityProp
-                }
-            )
+        Map.fromList
+          [ ("title", PV.titleValue titleRichText),
+            ("Status", PV.selectValue "In Progress"),
+            ("Priority", PV.selectValue "High")
           ]
 
-      -- Step 4: Create initial blocks for the page (optional)
+      -- Create initial blocks for the page (optional)
       -- Pages can be created with content already in them
       initialBlocks =
         Vector.fromList
@@ -243,13 +193,87 @@
   let PageObject {id = newPageId, url = newPageUrl} = newPage
   putStrLn $ "New page created. Access at: " <> Text.unpack newPageUrl
 
-  -- Retrieve the new page
+  -- Retrieve the new page and read typed properties
+  printHeader (Text.pack "Typed Property Values")
+
   retrievedPage <-
     runTest (Text.pack "Retrieving newly created page") $
       retrievePage methods newPageId
 
-  let PageObject {url = retrievedPageUrl} = retrievedPage
+  let PageObject {url = retrievedPageUrl, properties = pageProps, publicUrl = pagePubUrl} = retrievedPage
   putStrLn $ "Retrieved page URL: " <> Text.unpack retrievedPageUrl
+  putStrLn $ "Public URL: " <> show pagePubUrl
+
+  -- Pattern-match on typed property values from the retrieved page
+  putStrLn "Reading typed properties:"
+  case Map.lookup "Status" pageProps of
+    Just (PV.SelectValue _pid (Just (PV.SelectOptionValue _ optName optColor))) ->
+      putStrLn $ "  Status: " <> Text.unpack optName <> " (color: " <> show optColor <> ")"
+    Just (PV.SelectValue _pid Nothing) ->
+      putStrLn "  Status: (empty)"
+    _ ->
+      putStrLn "  Status: (not found or unexpected type)"
+
+  case Map.lookup "Priority" pageProps of
+    Just (PV.SelectValue _pid (Just (PV.SelectOptionValue _ optName _))) ->
+      putStrLn $ "  Priority: " <> Text.unpack optName
+    _ ->
+      putStrLn "  Priority: (not found)"
+
+  case Map.lookup "title" pageProps of
+    Just (PV.TitleValue _pid rts) ->
+      putStrLn $ "  Title rich text count: " <> show (Vector.length rts)
+    _ ->
+      putStrLn "  Title: (not found)"
+
+  -- Demonstrate retrievePageProperty endpoint
+  printHeader (Text.pack "Retrieve Page Property Item")
+
+  -- Get the property ID for "Status" from the property value
+  case Map.lookup "Status" pageProps of
+    Just (PV.SelectValue propId _) -> do
+      propItem <-
+        runTest (Text.pack "Retrieving 'Status' property item") $
+          retrievePageProperty methods newPageId propId Nothing Nothing
+      case propItem of
+        SinglePropertyItem pv ->
+          putStrLn $ "  Single property item: " <> show pv
+        PaginatedPropertyItems _list propType ->
+          putStrLn $ "  Paginated property items (type: " <> Text.unpack propType <> ")"
+    _ ->
+      putStrLn "  Skipping (Status property not found)"
+
+  -- Demonstrate auto-pagination with paginateAll
+  printHeader (Text.pack "Auto-Pagination")
+
+  allPages <-
+    runTest (Text.pack "Paginating all data source results") $
+      paginateAll $ \cursor ->
+        queryDataSource methods dsId $
+          DataSources.QueryDataSource
+            { filter = Nothing,
+              sorts = Nothing,
+              startCursor = cursor,
+              pageSize = Just 2, -- small page size to exercise pagination
+              inTrash = Nothing,
+              filterProperties = Nothing
+            }
+  putStrLn $ "Total pages collected via paginateAll: " <> show (Vector.length allPages)
+
+  -- Demonstrate typed error handling
+  printHeader (Text.pack "Typed Error Handling")
+
+  putStr "Requesting invalid page to trigger NotionError... "
+  let badPageId = UUID "00000000-0000-0000-0000-000000000000"
+  result <- Exception.try @NotionError (retrievePage methods badPageId)
+  case result of
+    Left notionErr -> do
+      putStrLn "caught!"
+      putStrLn $ "  code: " <> Text.unpack (code notionErr)
+      putStrLn $ "  message: " <> Text.unpack (message notionErr)
+      putStrLn $ "  status: " <> show (status notionErr)
+    Right _ ->
+      putStrLn "unexpectedly succeeded (page exists?)"
 
   -- Add blocks to the newly created page
   let additionalBlocks =
diff --git a/notion-client-example/MarkdownDemo.hs b/notion-client-example/MarkdownDemo.hs
--- a/notion-client-example/MarkdownDemo.hs
+++ b/notion-client-example/MarkdownDemo.hs
@@ -13,7 +13,6 @@
 where
 
 import Console (printHeader, printSuccess, runTest)
-import Data.Aeson qualified as Aeson
 import Data.Map (fromList)
 import Data.String (fromString)
 import Data.Text qualified as Text
@@ -21,6 +20,8 @@
 import Notion.V1 (Methods (..))
 import Notion.V1.Common (Parent (..), UUID (..))
 import Notion.V1.Pages
+import Notion.V1.PropertyValue qualified as PV
+import Notion.V1.RichText (RichText (..), RichTextContent (..), TextContent (..), defaultAnnotations)
 import Prelude hiding (id)
 
 -- | Run the Markdown API demonstration
@@ -33,8 +34,7 @@
   -- ---------------------------------------------------------------
   printHeader (Text.pack "Markdown: Create Page with Markdown")
 
-  let titleProp = mkTitleProp "Markdown Demo Page"
-      props = fromList [("title", PropertyValue {type_ = Title, value = Just titleProp})]
+  let props = fromList [("title", PV.titleValue (Vector.singleton (mkPlainRichText "Markdown Demo Page")))]
 
       -- Use 'markdown' instead of 'children' to set initial content.
       -- This is much simpler than constructing block JSON manually.
@@ -141,7 +141,7 @@
   printHeader (Text.pack "Move Page API")
 
   -- Create a target page to move into
-  let targetProps = fromList [("title", PropertyValue {type_ = Title, value = Just (mkTitleProp "Move Target")})]
+  let targetProps = fromList [("title", PV.titleValue (Vector.singleton (mkPlainRichText "Move Target")))]
       targetReq = mkCreatePage (PageParent {pageId = parentPageId}) targetProps
   targetPage <-
     runTest (Text.pack "Creating target parent page") $
@@ -220,10 +220,13 @@
   \\n\
   \This paragraph will be edited via **update_content** next."
 
--- | Helper to create a title property value
-mkTitleProp :: Text.Text -> Aeson.Value
-mkTitleProp t =
-  let textObj = Aeson.object [("content", Aeson.String t)]
-      textItem = Aeson.object [("text", textObj)]
-      titleArray = Aeson.Array (Vector.singleton textItem)
-   in Aeson.object [("title", titleArray)]
+-- | Helper to create a plain RichText from a string
+mkPlainRichText :: Text.Text -> RichText
+mkPlainRichText t =
+  RichText
+    { plainText = t,
+      href = Nothing,
+      annotations = defaultAnnotations,
+      type_ = "text",
+      content = TextContentWrapper (TextContent {content = t, link = Nothing})
+    }
diff --git a/notion-client-example/TemplateDemo.hs b/notion-client-example/TemplateDemo.hs
--- a/notion-client-example/TemplateDemo.hs
+++ b/notion-client-example/TemplateDemo.hs
@@ -11,7 +11,6 @@
 where
 
 import Console (printHeader, runTest)
-import Data.Aeson qualified as Aeson
 import Data.Map (fromList)
 import Data.String (fromString)
 import Data.Text qualified as Text
@@ -20,7 +19,9 @@
 import Notion.V1.Common (Parent (..))
 import Notion.V1.DataSources (ListTemplatesResponse (..), TemplateRef (..))
 import Notion.V1.Databases (DataSource (..), DatabaseObject (..))
-import Notion.V1.Pages (CreatePage (..), PageObject (..), PropertyValue (..), PropertyValueType (..), Template (..), UpdatePage (..), mkCreatePage, mkUpdatePage)
+import Notion.V1.Pages (CreatePage (..), PageObject (..), Template (..), UpdatePage (..))
+import Notion.V1.PropertyValue qualified as PV
+import Notion.V1.RichText (RichText (..), RichTextContent (..), TextContent (..), defaultAnnotations)
 import Prelude hiding (id)
 
 -- | Run the Template API demonstration
@@ -72,12 +73,7 @@
 
   if hasDefault
     then do
-      let titleProp =
-            let textObj = Aeson.object [("content", Aeson.String "Template Demo Page")]
-                textItem = Aeson.object [("text", textObj)]
-                titleArray = Aeson.Array (Vector.singleton textItem)
-             in Aeson.object [("title", titleArray)]
-          props = fromList [("title", PropertyValue {type_ = Title, value = Just titleProp})]
+      let props = fromList [("title", PV.titleValue (Vector.singleton (mkPlainRichText "Template Demo Page")))]
 
           -- Create a page with the default template.
           -- The template parameter tells the API to apply the data source's
@@ -126,3 +122,14 @@
       putStrLn $ "\nTo create a page with a specific template:"
       putStrLn $ "  Use: TemplateById " <> show firstTmplId <> " (Just \"America/New_York\")"
       putStrLn $ "  Template name: " <> Text.unpack firstTmplName
+
+-- | Helper to create a plain RichText from a string
+mkPlainRichText :: Text.Text -> RichText
+mkPlainRichText t =
+  RichText
+    { plainText = t,
+      href = Nothing,
+      annotations = defaultAnnotations,
+      type_ = "text",
+      content = TextContentWrapper (TextContent {content = t, link = Nothing})
+    }
diff --git a/notion-client.cabal b/notion-client.cabal
--- a/notion-client.cabal
+++ b/notion-client.cabal
@@ -1,6 +1,6 @@
 cabal-version:      3.4
 name:               notion-client
-version:            0.3.1.0
+version:            0.5.0.0
 synopsis:           Type-safe Haskell client for the Notion API
 description:
   This package provides comprehensive and type-safe bindings
@@ -44,6 +44,7 @@
     , filepath                  >=1.4      && <1.6
     , http-api-data             >=0.6      && <0.7
     , http-client-tls           >=0.3      && <0.4
+    , scientific                >=0.3      && <0.4
     , servant                   >=0.20     && <0.21
     , servant-client            >=0.20     && <0.21
     , servant-multipart-api     >=0.12     && <0.13
@@ -63,9 +64,12 @@
     Notion.V1.Databases
     Notion.V1.DataSources
     Notion.V1.Error
+    Notion.V1.Filter
     Notion.V1.ListOf
     Notion.V1.Pages
     Notion.V1.Pagination
+    Notion.V1.Properties
+    Notion.V1.PropertyValue
     Notion.V1.RichText
     Notion.V1.Search
     Notion.V1.Users
@@ -100,6 +104,7 @@
     , http-client
     , http-client-tls
     , notion-client
+    , scientific
     , servant-client
     , tasty
     , tasty-hunit
@@ -134,6 +139,7 @@
     , base
     , containers
     , notion-client
+    , scientific
     , text
     , unordered-containers
     , vector
diff --git a/src/Notion/V1.hs b/src/Notion/V1.hs
--- a/src/Notion/V1.hs
+++ b/src/Notion/V1.hs
@@ -49,8 +49,9 @@
 import Notion.V1.DataSources qualified as DataSources
 import Notion.V1.Databases (CreateDatabase, DatabaseID, DatabaseObject, QueryDatabase, UpdateDatabase)
 import Notion.V1.Databases qualified as Databases
+import Notion.V1.Error (parseNotionError)
 import Notion.V1.ListOf (ListOf (..))
-import Notion.V1.Pages (CreatePage, MovePage, PageID, PageMarkdown, PageObject, UpdatePage, UpdatePageMarkdown)
+import Notion.V1.Pages (CreatePage, MovePage, PageID, PageMarkdown, PageObject, PropertyItemResponse, UpdatePage, UpdatePageMarkdown)
 import Notion.V1.Pages qualified as Pages
 import Notion.V1.Search (SearchRequest)
 import Notion.V1.Search qualified as Search
@@ -97,6 +98,7 @@
         :<|> ( retrievePage
                  :<|> createPage
                  :<|> updatePage
+                 :<|> retrievePageProperty
                  :<|> retrievePageMarkdown
                  :<|> updatePageMarkdown
                  :<|> movePage
@@ -131,7 +133,9 @@
     run clientM = do
       result <- Client.runClientM clientM clientEnv
       case result of
-        Left exception -> Exception.throwIO exception
+        Left err -> case parseNotionError err of
+          Just notionErr -> Exception.throwIO notionErr
+          Nothing -> Exception.throwIO err
         Right a -> return a
 
     -- Keep the ListOf structure
@@ -149,6 +153,7 @@
     createDatabase :: CreateDatabase -> IO DatabaseObject,
     retrieveDatabase :: DatabaseID -> IO DatabaseObject,
     updateDatabase :: DatabaseID -> UpdateDatabase -> IO DatabaseObject,
+    -- | @Deprecated: Use 'queryDataSource' instead.@
     queryDatabase :: DatabaseID -> QueryDatabase -> IO (ListOf PageObject),
     -- \* Data Sources
     retrieveDataSource :: DataSourceID -> IO DataSourceObject,
@@ -169,6 +174,17 @@
     createPage :: CreatePage -> IO PageObject,
     retrievePage :: PageID -> IO PageObject,
     updatePage :: PageID -> UpdatePage -> IO PageObject,
+    -- | Retrieve a single page property item.
+    -- For title, rich_text, relation, and people properties, the response may be paginated.
+    retrievePageProperty ::
+      PageID ->
+      Text ->
+      -- \^ property_id
+      Maybe Text ->
+      -- \^ start_cursor
+      Maybe Natural ->
+      -- \^ page_size
+      IO PropertyItemResponse,
     retrievePageMarkdown ::
       PageID ->
       Maybe Bool ->
diff --git a/src/Notion/V1/DataSources.hs b/src/Notion/V1/DataSources.hs
--- a/src/Notion/V1/DataSources.hs
+++ b/src/Notion/V1/DataSources.hs
@@ -20,11 +20,16 @@
 where
 
 import Control.Applicative ((<|>))
-import Data.Aeson ((.:), (.:?))
+import Data.Aeson ((.:), (.:?), (.=))
+import Data.Aeson qualified as Aeson
+import Data.Aeson.Key qualified as Key
+import Data.Map qualified as Map
 import Notion.Prelude
 import Notion.V1.Common (Cover, Icon, ObjectType, Parent, UUID)
+import Notion.V1.Filter (Filter, Sort)
 import Notion.V1.ListOf (ListOf)
 import Notion.V1.Pages (PageObject)
+import Notion.V1.Properties (PropertySchema)
 import Notion.V1.RichText (RichText)
 import Notion.V1.Users (UserReference)
 import Prelude hiding (id)
@@ -41,7 +46,7 @@
     lastEditedBy :: UserReference,
     title :: Vector RichText,
     description :: Vector RichText,
-    properties :: Value,
+    properties :: Map Text PropertySchema,
     url :: Text,
     parent :: Parent,
     databaseParent :: Maybe Parent,
@@ -82,35 +87,57 @@
 -- | Create data source request
 data CreateDataSource = CreateDataSource
   { parent :: Parent,
-    properties :: Value,
+    properties :: Map Text PropertySchema,
     title :: Maybe (Vector RichText),
-    icon :: Maybe Icon
+    description :: Maybe (Vector RichText),
+    icon :: Maybe Icon,
+    cover :: Maybe Cover
   }
   deriving stock (Generic, Show)
 
 instance ToJSON CreateDataSource where
   toJSON = genericToJSON aesonOptions
 
--- | Update data source request
+-- | Update data source request.
+--
+-- The @properties@ field uses @Maybe (Maybe PropertySchema)@ to distinguish between:
+--
+-- * @Nothing@ (outer): omit the properties field entirely (don't touch properties)
+-- * @Just (Map ...)@ with @Just schema@: add or update a property
+-- * @Just (Map ...)@ with @Nothing@: delete a property (emits @null@ in JSON)
 data UpdateDataSource = UpdateDataSource
   { title :: Maybe (Vector RichText),
     icon :: Maybe Icon,
-    properties :: Maybe Value,
+    properties :: Maybe (Map Text (Maybe PropertySchema)),
     inTrash :: Maybe Bool,
     parent :: Maybe Parent
   }
   deriving stock (Generic, Show)
 
 instance ToJSON UpdateDataSource where
-  toJSON = genericToJSON aesonOptions
+  toJSON UpdateDataSource {..} =
+    Aeson.object $
+      maybe [] (\t -> ["title" .= t]) title
+        <> maybe [] (\i -> ["icon" .= i]) icon
+        <> maybe [] (\p -> ["properties" .= mapWithNulls p]) properties
+        <> maybe [] (\t -> ["in_trash" .= t]) inTrash
+        <> maybe [] (\p -> ["parent" .= p]) parent
+    where
+      -- Emit Nothing values as JSON null (not omitted)
+      mapWithNulls :: Map Text (Maybe PropertySchema) -> Value
+      mapWithNulls m =
+        Aeson.object $ map (\(k, v) -> Key.fromText k .= v) (Map.toList m)
 
 -- | Query data source request
 data QueryDataSource = QueryDataSource
-  { filter :: Maybe Value,
-    sorts :: Maybe [Value],
+  { filter :: Maybe Filter,
+    sorts :: Maybe [Sort],
     startCursor :: Maybe Text,
     pageSize :: Maybe Natural,
-    inTrash :: Maybe Bool
+    inTrash :: Maybe Bool,
+    -- | Limit which properties are returned in the response.
+    -- Each element is a property ID (not name).
+    filterProperties :: Maybe [Text]
   }
   deriving stock (Generic, Show)
 
diff --git a/src/Notion/V1/Databases.hs b/src/Notion/V1/Databases.hs
--- a/src/Notion/V1/Databases.hs
+++ b/src/Notion/V1/Databases.hs
@@ -18,8 +18,10 @@
 import Data.Aeson ((.:), (.:?))
 import Notion.Prelude
 import Notion.V1.Common (Cover, Icon, ObjectType (..), Parent, UUID)
+import Notion.V1.Filter (Filter, Sort)
 import Notion.V1.ListOf (ListOf)
 import Notion.V1.Pages (PageObject)
+import Notion.V1.Properties (PropertySchema)
 import Notion.V1.RichText (RichText)
 import Notion.V1.Users (UserReference)
 import Prelude hiding (id)
@@ -50,7 +52,7 @@
     lastEditedBy :: Maybe UserReference,
     title :: Vector RichText,
     description :: Maybe (Vector RichText),
-    properties :: Maybe Value,
+    properties :: Maybe (Map Text PropertySchema),
     icon :: Maybe Icon,
     cover :: Maybe Cover,
     url :: Text,
@@ -93,7 +95,7 @@
 -- | Initial data source configuration for database creation.
 -- Contains the property schema for the database's first data source.
 newtype InitialDataSource = InitialDataSource
-  { properties :: Value
+  { properties :: Map Text PropertySchema
   }
   deriving stock (Generic, Show)
 
@@ -129,6 +131,7 @@
     cover :: Maybe Cover,
     description :: Maybe (Vector RichText),
     isInline :: Maybe Bool,
+    isLocked :: Maybe Bool,
     inTrash :: Maybe Bool,
     parent :: Maybe Parent
   }
@@ -139,10 +142,13 @@
 
 -- | Query database request
 data QueryDatabase = QueryDatabase
-  { filter :: Maybe Value,
-    sorts :: Maybe [Value],
+  { filter :: Maybe Filter,
+    sorts :: Maybe [Sort],
     startCursor :: Maybe Text,
-    pageSize :: Maybe Natural
+    pageSize :: Maybe Natural,
+    -- | Limit which properties are returned in the response.
+    -- Each element is a property ID (not name).
+    filterProperties :: Maybe [Text]
   }
   deriving stock (Generic, Show)
 
diff --git a/src/Notion/V1/Error.hs b/src/Notion/V1/Error.hs
--- a/src/Notion/V1/Error.hs
+++ b/src/Notion/V1/Error.hs
@@ -2,10 +2,14 @@
 module Notion.V1.Error
   ( -- * Error types
     NotionError (..),
+    parseNotionError,
   )
 where
 
+import Control.Exception (Exception)
+import Data.Aeson qualified as Aeson
 import Notion.Prelude
+import Servant.Client qualified as Client
 
 -- | Notion API error response
 data NotionError = NotionError
@@ -17,5 +21,21 @@
   }
   deriving stock (Generic, Show)
 
+instance Exception NotionError
+
 instance FromJSON NotionError where
   parseJSON = genericParseJSON aesonOptions
+
+instance ToJSON NotionError where
+  toJSON = genericToJSON aesonOptions
+
+-- | Try to parse a 'NotionError' from a Servant 'Client.ClientError'.
+--
+-- Returns 'Just' if the error is a 'Client.FailureResponse' with a JSON body
+-- that can be decoded as a 'NotionError'. Returns 'Nothing' for network errors,
+-- non-JSON responses, or responses that don't match the Notion error format.
+parseNotionError :: Client.ClientError -> Maybe NotionError
+parseNotionError = \case
+  Client.FailureResponse _req resp ->
+    Aeson.decode (Client.responseBody resp)
+  _ -> Nothing
diff --git a/src/Notion/V1/Filter.hs b/src/Notion/V1/Filter.hs
new file mode 100644
--- /dev/null
+++ b/src/Notion/V1/Filter.hs
@@ -0,0 +1,412 @@
+-- | Typed query filters and sorts for Notion database and data source queries.
+--
+-- This module provides a type-safe DSL for constructing filter and sort
+-- conditions, replacing raw @Value@ construction.
+--
+-- Example usage:
+--
+-- @
+-- let myFilter = And
+--       [ PropertyFilter "Status" (SelectCondition (SelectEquals "Done"))
+--       , PropertyFilter "Priority" (SelectCondition (SelectEquals "High"))
+--       ]
+--     mySorts = [PropertySort "Name" Ascending]
+-- @
+module Notion.V1.Filter
+  ( -- * Filters
+    Filter (..),
+    PropertyCondition (..),
+    TimestampType (..),
+
+    -- * Filter conditions
+    TextCondition (..),
+    NumberCondition (..),
+    CheckboxCondition (..),
+    SelectCondition (..),
+    MultiSelectCondition (..),
+    DateCondition (..),
+    PeopleCondition (..),
+    FilesCondition (..),
+    RelationCondition (..),
+    StatusCondition (..),
+    UniqueIdCondition (..),
+    VerificationCondition (..),
+    FormulaCondition (..),
+    RollupCondition (..),
+
+    -- * Sorts
+    Sort (..),
+    SortDirection (..),
+  )
+where
+
+import Data.Aeson ((.=))
+import Data.Aeson qualified as Aeson
+import Data.Aeson.Key qualified as Key
+import Data.Scientific (Scientific)
+import Notion.Prelude
+
+-- | Timestamp type for timestamp filters and sorts.
+data TimestampType
+  = FilterCreatedTime
+  | FilterLastEditedTime
+  deriving stock (Eq, Show, Generic)
+
+timestampTypeToText :: TimestampType -> Text
+timestampTypeToText FilterCreatedTime = "created_time"
+timestampTypeToText FilterLastEditedTime = "last_edited_time"
+
+-- | Top-level filter type for querying databases and data sources.
+--
+-- Filters can be compound (@And@ / @Or@, nesting up to 2 levels per Notion API),
+-- property filters (targeting a named property), or timestamp filters.
+data Filter
+  = And [Filter]
+  | Or [Filter]
+  | PropertyFilter Text PropertyCondition
+  | TimestampFilter TimestampType DateCondition
+  deriving stock (Eq, Show, Generic)
+
+instance ToJSON Filter where
+  toJSON (And filters) = Aeson.object ["and" .= filters]
+  toJSON (Or filters) = Aeson.object ["or" .= filters]
+  toJSON (PropertyFilter propName condition) =
+    let condObj = propertyConditionToObject condition
+     in Aeson.object $ ["property" .= propName] <> condObj
+  toJSON (TimestampFilter tsType condition) =
+    let tsKey = timestampTypeToText tsType
+     in Aeson.object
+          [ "timestamp" .= tsKey,
+            Key.fromText tsKey .= dateConditionToValue condition
+          ]
+
+-- | Property-type-specific filter condition.
+--
+-- Each constructor maps to the JSON key the Notion API expects
+-- (e.g., 'TitleCondition' serializes under @\"title\"@).
+data PropertyCondition
+  = TitleCondition TextCondition
+  | RichTextCondition TextCondition
+  | NumberCondition NumberCondition
+  | CheckboxCondition CheckboxCondition
+  | SelectCondition SelectCondition
+  | MultiSelectCondition MultiSelectCondition
+  | DateCondition DateCondition
+  | PeopleCondition PeopleCondition
+  | FilesCondition FilesCondition
+  | RelationCondition RelationCondition
+  | StatusCondition StatusCondition
+  | UniqueIdCondition UniqueIdCondition
+  | VerificationCondition VerificationCondition
+  | FormulaCondition FormulaCondition
+  | RollupCondition RollupCondition
+  | CreatedTimeCondition DateCondition
+  | CreatedByCondition PeopleCondition
+  | LastEditedTimeCondition DateCondition
+  | LastEditedByCondition PeopleCondition
+  | PhoneNumberCondition TextCondition
+  | UrlCondition TextCondition
+  | EmailCondition TextCondition
+  deriving stock (Eq, Show, Generic)
+
+-- | Convert a PropertyCondition to key-value pairs for inclusion in a JSON object.
+propertyConditionToObject :: PropertyCondition -> [(Aeson.Key, Aeson.Value)]
+propertyConditionToObject = \case
+  TitleCondition c -> [("title", textConditionToValue c)]
+  RichTextCondition c -> [("rich_text", textConditionToValue c)]
+  NumberCondition c -> [("number", numberConditionToValue c)]
+  CheckboxCondition c -> [("checkbox", checkboxConditionToValue c)]
+  SelectCondition c -> [("select", selectConditionToValue c)]
+  MultiSelectCondition c -> [("multi_select", multiSelectConditionToValue c)]
+  DateCondition c -> [("date", dateConditionToValue c)]
+  PeopleCondition c -> [("people", peopleConditionToValue c)]
+  FilesCondition c -> [("files", filesConditionToValue c)]
+  RelationCondition c -> [("relation", relationConditionToValue c)]
+  StatusCondition c -> [("status", statusConditionToValue c)]
+  UniqueIdCondition c -> [("unique_id", uniqueIdConditionToValue c)]
+  VerificationCondition c -> [("verification", verificationConditionToValue c)]
+  FormulaCondition c -> [("formula", formulaConditionToValue c)]
+  RollupCondition c -> [("rollup", rollupConditionToValue c)]
+  CreatedTimeCondition c -> [("created_time", dateConditionToValue c)]
+  CreatedByCondition c -> [("created_by", peopleConditionToValue c)]
+  LastEditedTimeCondition c -> [("last_edited_time", dateConditionToValue c)]
+  LastEditedByCondition c -> [("last_edited_by", peopleConditionToValue c)]
+  PhoneNumberCondition c -> [("phone_number", textConditionToValue c)]
+  UrlCondition c -> [("url", textConditionToValue c)]
+  EmailCondition c -> [("email", textConditionToValue c)]
+
+-- | Text filter conditions for title, rich_text, phone_number, url, and email properties.
+data TextCondition
+  = TextEquals Text
+  | TextDoesNotEqual Text
+  | TextContains Text
+  | TextDoesNotContain Text
+  | TextStartsWith Text
+  | TextEndsWith Text
+  | TextIsEmpty
+  | TextIsNotEmpty
+  deriving stock (Eq, Show, Generic)
+
+textConditionToValue :: TextCondition -> Aeson.Value
+textConditionToValue = \case
+  TextEquals v -> Aeson.object ["equals" .= v]
+  TextDoesNotEqual v -> Aeson.object ["does_not_equal" .= v]
+  TextContains v -> Aeson.object ["contains" .= v]
+  TextDoesNotContain v -> Aeson.object ["does_not_contain" .= v]
+  TextStartsWith v -> Aeson.object ["starts_with" .= v]
+  TextEndsWith v -> Aeson.object ["ends_with" .= v]
+  TextIsEmpty -> Aeson.object ["is_empty" .= True]
+  TextIsNotEmpty -> Aeson.object ["is_not_empty" .= True]
+
+-- | Number filter conditions.
+data NumberCondition
+  = NumEquals Scientific
+  | NumDoesNotEqual Scientific
+  | NumGreaterThan Scientific
+  | NumGreaterThanOrEqualTo Scientific
+  | NumLessThan Scientific
+  | NumLessThanOrEqualTo Scientific
+  | NumIsEmpty
+  | NumIsNotEmpty
+  deriving stock (Eq, Show, Generic)
+
+numberConditionToValue :: NumberCondition -> Aeson.Value
+numberConditionToValue = \case
+  NumEquals v -> Aeson.object ["equals" .= v]
+  NumDoesNotEqual v -> Aeson.object ["does_not_equal" .= v]
+  NumGreaterThan v -> Aeson.object ["greater_than" .= v]
+  NumGreaterThanOrEqualTo v -> Aeson.object ["greater_than_or_equal_to" .= v]
+  NumLessThan v -> Aeson.object ["less_than" .= v]
+  NumLessThanOrEqualTo v -> Aeson.object ["less_than_or_equal_to" .= v]
+  NumIsEmpty -> Aeson.object ["is_empty" .= True]
+  NumIsNotEmpty -> Aeson.object ["is_not_empty" .= True]
+
+-- | Checkbox filter conditions.
+data CheckboxCondition
+  = CheckboxEquals Bool
+  | CheckboxDoesNotEqual Bool
+  deriving stock (Eq, Show, Generic)
+
+checkboxConditionToValue :: CheckboxCondition -> Aeson.Value
+checkboxConditionToValue = \case
+  CheckboxEquals v -> Aeson.object ["equals" .= v]
+  CheckboxDoesNotEqual v -> Aeson.object ["does_not_equal" .= v]
+
+-- | Select filter conditions.
+data SelectCondition
+  = SelectEquals Text
+  | SelectDoesNotEqual Text
+  | SelectIsEmpty
+  | SelectIsNotEmpty
+  deriving stock (Eq, Show, Generic)
+
+selectConditionToValue :: SelectCondition -> Aeson.Value
+selectConditionToValue = \case
+  SelectEquals v -> Aeson.object ["equals" .= v]
+  SelectDoesNotEqual v -> Aeson.object ["does_not_equal" .= v]
+  SelectIsEmpty -> Aeson.object ["is_empty" .= True]
+  SelectIsNotEmpty -> Aeson.object ["is_not_empty" .= True]
+
+-- | Multi-select filter conditions.
+data MultiSelectCondition
+  = MultiSelectContains Text
+  | MultiSelectDoesNotContain Text
+  | MultiSelectIsEmpty
+  | MultiSelectIsNotEmpty
+  deriving stock (Eq, Show, Generic)
+
+multiSelectConditionToValue :: MultiSelectCondition -> Aeson.Value
+multiSelectConditionToValue = \case
+  MultiSelectContains v -> Aeson.object ["contains" .= v]
+  MultiSelectDoesNotContain v -> Aeson.object ["does_not_contain" .= v]
+  MultiSelectIsEmpty -> Aeson.object ["is_empty" .= True]
+  MultiSelectIsNotEmpty -> Aeson.object ["is_not_empty" .= True]
+
+-- | Date filter conditions. Also used for timestamp filters and created_time/last_edited_time.
+--
+-- Text values are ISO 8601 date strings (e.g., @\"2024-01-15\"@ or @\"2024-01-15T00:00:00Z\"@).
+data DateCondition
+  = DateAfter Text
+  | DateBefore Text
+  | DateEquals Text
+  | DateOnOrAfter Text
+  | DateOnOrBefore Text
+  | DateIsEmpty
+  | DateIsNotEmpty
+  | DateNextWeek
+  | DateNextMonth
+  | DateNextYear
+  | DateThisWeek
+  | DatePastWeek
+  | DatePastMonth
+  | DatePastYear
+  deriving stock (Eq, Show, Generic)
+
+dateConditionToValue :: DateCondition -> Aeson.Value
+dateConditionToValue = \case
+  DateAfter v -> Aeson.object ["after" .= v]
+  DateBefore v -> Aeson.object ["before" .= v]
+  DateEquals v -> Aeson.object ["equals" .= v]
+  DateOnOrAfter v -> Aeson.object ["on_or_after" .= v]
+  DateOnOrBefore v -> Aeson.object ["on_or_before" .= v]
+  DateIsEmpty -> Aeson.object ["is_empty" .= True]
+  DateIsNotEmpty -> Aeson.object ["is_not_empty" .= True]
+  DateNextWeek -> Aeson.object ["next_week" .= Aeson.object []]
+  DateNextMonth -> Aeson.object ["next_month" .= Aeson.object []]
+  DateNextYear -> Aeson.object ["next_year" .= Aeson.object []]
+  DateThisWeek -> Aeson.object ["this_week" .= Aeson.object []]
+  DatePastWeek -> Aeson.object ["past_week" .= Aeson.object []]
+  DatePastMonth -> Aeson.object ["past_month" .= Aeson.object []]
+  DatePastYear -> Aeson.object ["past_year" .= Aeson.object []]
+
+-- | People filter conditions. The Text value is a user UUID.
+data PeopleCondition
+  = PeopleContains Text
+  | PeopleDoesNotContain Text
+  | PeopleIsEmpty
+  | PeopleIsNotEmpty
+  deriving stock (Eq, Show, Generic)
+
+peopleConditionToValue :: PeopleCondition -> Aeson.Value
+peopleConditionToValue = \case
+  PeopleContains v -> Aeson.object ["contains" .= v]
+  PeopleDoesNotContain v -> Aeson.object ["does_not_contain" .= v]
+  PeopleIsEmpty -> Aeson.object ["is_empty" .= True]
+  PeopleIsNotEmpty -> Aeson.object ["is_not_empty" .= True]
+
+-- | Files filter conditions.
+data FilesCondition
+  = FilesIsEmpty
+  | FilesIsNotEmpty
+  deriving stock (Eq, Show, Generic)
+
+filesConditionToValue :: FilesCondition -> Aeson.Value
+filesConditionToValue = \case
+  FilesIsEmpty -> Aeson.object ["is_empty" .= True]
+  FilesIsNotEmpty -> Aeson.object ["is_not_empty" .= True]
+
+-- | Relation filter conditions. The Text value is a page UUID.
+data RelationCondition
+  = RelationContains Text
+  | RelationDoesNotContain Text
+  | RelationIsEmpty
+  | RelationIsNotEmpty
+  deriving stock (Eq, Show, Generic)
+
+relationConditionToValue :: RelationCondition -> Aeson.Value
+relationConditionToValue = \case
+  RelationContains v -> Aeson.object ["contains" .= v]
+  RelationDoesNotContain v -> Aeson.object ["does_not_contain" .= v]
+  RelationIsEmpty -> Aeson.object ["is_empty" .= True]
+  RelationIsNotEmpty -> Aeson.object ["is_not_empty" .= True]
+
+-- | Status filter conditions.
+data StatusCondition
+  = StatusEquals Text
+  | StatusDoesNotEqual Text
+  | StatusIsEmpty
+  | StatusIsNotEmpty
+  deriving stock (Eq, Show, Generic)
+
+statusConditionToValue :: StatusCondition -> Aeson.Value
+statusConditionToValue = \case
+  StatusEquals v -> Aeson.object ["equals" .= v]
+  StatusDoesNotEqual v -> Aeson.object ["does_not_equal" .= v]
+  StatusIsEmpty -> Aeson.object ["is_empty" .= True]
+  StatusIsNotEmpty -> Aeson.object ["is_not_empty" .= True]
+
+-- | Unique ID filter conditions.
+data UniqueIdCondition
+  = UniqueIdEquals Natural
+  | UniqueIdDoesNotEqual Natural
+  | UniqueIdGreaterThan Natural
+  | UniqueIdGreaterThanOrEqualTo Natural
+  | UniqueIdLessThan Natural
+  | UniqueIdLessThanOrEqualTo Natural
+  deriving stock (Eq, Show, Generic)
+
+uniqueIdConditionToValue :: UniqueIdCondition -> Aeson.Value
+uniqueIdConditionToValue = \case
+  UniqueIdEquals v -> Aeson.object ["equals" .= v]
+  UniqueIdDoesNotEqual v -> Aeson.object ["does_not_equal" .= v]
+  UniqueIdGreaterThan v -> Aeson.object ["greater_than" .= v]
+  UniqueIdGreaterThanOrEqualTo v -> Aeson.object ["greater_than_or_equal_to" .= v]
+  UniqueIdLessThan v -> Aeson.object ["less_than" .= v]
+  UniqueIdLessThanOrEqualTo v -> Aeson.object ["less_than_or_equal_to" .= v]
+
+-- | Verification filter condition.
+-- The Text is one of @\"verified\"@, @\"expired\"@, or @\"none\"@.
+data VerificationCondition
+  = VerificationStatus Text
+  deriving stock (Eq, Show, Generic)
+
+verificationConditionToValue :: VerificationCondition -> Aeson.Value
+verificationConditionToValue (VerificationStatus v) =
+  Aeson.object ["status" .= v]
+
+-- | Formula filter condition, wrapping a condition by the formula's return type.
+data FormulaCondition
+  = FormulaString TextCondition
+  | FormulaNumber NumberCondition
+  | FormulaDate DateCondition
+  | FormulaCheckbox CheckboxCondition
+  deriving stock (Eq, Show, Generic)
+
+formulaConditionToValue :: FormulaCondition -> Aeson.Value
+formulaConditionToValue = \case
+  FormulaString c -> Aeson.object ["string" .= textConditionToValue c]
+  FormulaNumber c -> Aeson.object ["number" .= numberConditionToValue c]
+  FormulaDate c -> Aeson.object ["date" .= dateConditionToValue c]
+  FormulaCheckbox c -> Aeson.object ["checkbox" .= checkboxConditionToValue c]
+
+-- | Rollup filter condition.
+data RollupCondition
+  = RollupAny PropertyCondition
+  | RollupEvery PropertyCondition
+  | RollupNone PropertyCondition
+  | RollupNumber NumberCondition
+  | RollupDate DateCondition
+  deriving stock (Eq, Show, Generic)
+
+rollupConditionToValue :: RollupCondition -> Aeson.Value
+rollupConditionToValue = \case
+  RollupAny c -> Aeson.object ["any" .= conditionInnerValue c]
+  RollupEvery c -> Aeson.object ["every" .= conditionInnerValue c]
+  RollupNone c -> Aeson.object ["none" .= conditionInnerValue c]
+  RollupNumber c -> Aeson.object ["number" .= numberConditionToValue c]
+  RollupDate c -> Aeson.object ["date" .= dateConditionToValue c]
+  where
+    conditionInnerValue :: PropertyCondition -> Aeson.Value
+    conditionInnerValue cond = Aeson.object (propertyConditionToObject cond)
+
+-- =====================================================================
+-- Sorts
+-- =====================================================================
+
+-- | Sort direction for query sorts.
+data SortDirection
+  = Ascending
+  | Descending
+  deriving stock (Eq, Show, Generic)
+
+instance ToJSON SortDirection where
+  toJSON Ascending = Aeson.String "ascending"
+  toJSON Descending = Aeson.String "descending"
+
+-- | Sort specification for querying databases and data sources.
+data Sort
+  = PropertySort Text SortDirection
+  | TimestampSort TimestampType SortDirection
+  deriving stock (Eq, Show, Generic)
+
+instance ToJSON Sort where
+  toJSON (PropertySort propName dir) =
+    Aeson.object
+      [ "property" .= propName,
+        "direction" .= dir
+      ]
+  toJSON (TimestampSort tsType dir) =
+    Aeson.object
+      [ "timestamp" .= timestampTypeToText tsType,
+        "direction" .= dir
+      ]
diff --git a/src/Notion/V1/Pages.hs b/src/Notion/V1/Pages.hs
--- a/src/Notion/V1/Pages.hs
+++ b/src/Notion/V1/Pages.hs
@@ -6,13 +6,12 @@
     CreatePage (..),
     UpdatePage (..),
     PageProperties,
-    PropertyValue (..),
-    PropertyItem (..),
-    PropertyValueType (..),
-    SelectOption (..),
     mkCreatePage,
     mkUpdatePage,
 
+    -- * Property item
+    PropertyItemResponse (..),
+
     -- * Markdown
     PageMarkdown (..),
     UpdatePageMarkdown (..),
@@ -36,11 +35,12 @@
 import Control.Applicative ((<|>))
 import Data.Aeson ((.:), (.:?), (.=))
 import Data.Aeson qualified as Aeson
-import Data.Aeson.Key qualified as Key
 import Data.Aeson.KeyMap qualified as KeyMap
-import Notion.Prelude hiding (Number)
+import Notion.Prelude
 import Notion.V1.Blocks (Position)
 import Notion.V1.Common (Cover, Icon, ObjectType (..), Parent, UUID)
+import Notion.V1.ListOf (ListOf)
+import Notion.V1.PropertyValue (PropertyValue)
 import Notion.V1.Users (UserReference)
 
 -- | Page ID
@@ -57,8 +57,9 @@
     icon :: Maybe Icon,
     parent :: Parent,
     inTrash :: Bool,
-    properties :: Map Text PropertyItem,
+    properties :: Map Text PropertyValue,
     url :: Text,
+    publicUrl :: Maybe Text,
     object :: ObjectType
   }
   deriving stock (Generic, Show)
@@ -79,13 +80,14 @@
       inTrash <- (o .: "in_trash") <|> (o .: "is_archived") <|> (o .: "archived") <|> pure False
       properties <- o .: "properties"
       url <- o .: "url"
+      publicUrl <- o .:? "public_url"
       object <- o .: "object"
       return PageObject {..}
     _ -> fail "Expected object for PageObject"
 
 instance ToJSON PageObject where
   toJSON PageObject {..} =
-    Aeson.object
+    Aeson.object $
       [ "id" .= id,
         "created_time" .= posixToISO8601 createdTime,
         "last_edited_time" .= posixToISO8601 lastEditedTime,
@@ -99,6 +101,7 @@
         "url" .= url,
         "object" .= object
       ]
+        <> maybe [] (\pu -> ["public_url" .= pu]) publicUrl
 
 -- | Template configuration for page creation and updates.
 --
@@ -188,138 +191,6 @@
 -- | Page properties map
 type PageProperties = Map Text PropertyValue
 
--- | Property value type for creating or updating pages
-data PropertyValue = PropertyValue
-  { type_ :: PropertyValueType,
-    value :: Maybe Value
-  }
-  deriving stock (Generic, Show)
-
-instance ToJSON PropertyValue where
-  -- Direct conversion - the important part is that we're NOT nesting under "type" or "value" fields
-  toJSON PropertyValue {type_ = Title, value = Just v} =
-    -- For title property, directly put the array into a "title" field
-    case v of
-      Object o ->
-        if KeyMap.member "title" o
-          then Aeson.Object o -- Use this object directly, just have to wrap it
-          else Aeson.object ["title" .= v] -- Otherwise wrap it
-      _ -> Aeson.object ["title" .= ([] :: [Value])]
-  -- Handle other property types
-  toJSON PropertyValue {type_ = t, value = Just v} =
-    -- Just directly use the value as the property content
-    case v of
-      Object o -> Aeson.Object o -- Use the object directly, but wrap it
-      _ -> Aeson.object [] -- Empty object as fallback
-
-  -- Empty property values
-  toJSON PropertyValue {type_} = Aeson.object []
-
--- | Property item returned by API
-data PropertyItem = PropertyItem
-  { id :: Text,
-    type_ :: PropertyValueType,
-    value :: Maybe Value
-  }
-  deriving stock (Generic, Show)
-
-instance FromJSON PropertyItem where
-  parseJSON = \case
-    Object o -> do
-      id <- o .: "id"
-      type_ <- o .: "type"
-      -- The Notion API stores the property value under a type-specific key
-      -- (e.g., "title", "select", "rich_text"), not under a generic "value" key.
-      let typeKey = Key.fromText (propertyTypeToKey type_)
-          value = KeyMap.lookup typeKey o >>= \v -> Just v
-      return PropertyItem {..}
-    _ -> fail "Expected object for PropertyItem"
-
-instance ToJSON PropertyItem where
-  toJSON PropertyItem {..} =
-    let base = ["id" .= id, "type" .= type_]
-        typeKey = Key.fromText (propertyTypeToKey type_)
-        valueField = case value of
-          Just v -> [typeKey .= v]
-          Nothing -> []
-     in Aeson.object (base <> valueField)
-
--- | Map a PropertyValueType to the JSON key the Notion API uses for its value
-propertyTypeToKey :: PropertyValueType -> Text
-propertyTypeToKey = \case
-  Title -> "title"
-  RichText -> "rich_text"
-  Number -> "number"
-  Select -> "select"
-  MultiSelect -> "multi_select"
-  Date -> "date"
-  People -> "people"
-  Files -> "files"
-  Checkbox -> "checkbox"
-  Url -> "url"
-  Email -> "email"
-  PhoneNumber -> "phone_number"
-  Formula -> "formula"
-  Relation -> "relation"
-  Rollup -> "rollup"
-  CreatedTime -> "created_time"
-  CreatedBy -> "created_by"
-  LastEditedTime -> "last_edited_time"
-  LastEditedBy -> "last_edited_by"
-  Status -> "status"
-  UniqueId -> "unique_id"
-  Place -> "place"
-  Button -> "button"
-  Verification -> "verification"
-
--- | Property value types
-data PropertyValueType
-  = Title
-  | RichText
-  | Number
-  | Select
-  | MultiSelect
-  | Date
-  | People
-  | Files
-  | Checkbox
-  | Url
-  | Email
-  | PhoneNumber
-  | Formula
-  | Relation
-  | Rollup
-  | CreatedTime
-  | CreatedBy
-  | LastEditedTime
-  | LastEditedBy
-  | Status
-  | UniqueId
-  | Place
-  | Button
-  | Verification
-  deriving stock (Eq, Generic, Show)
-
-instance FromJSON PropertyValueType where
-  parseJSON = genericParseJSON aesonOptions
-
-instance ToJSON PropertyValueType where
-  toJSON = genericToJSON aesonOptions
-
--- | Select option
-data SelectOption = SelectOption
-  { id :: Maybe Text,
-    name :: Text,
-    color :: Maybe Text
-  }
-  deriving stock (Generic, Show)
-
-instance FromJSON SelectOption where
-  parseJSON = genericParseJSON aesonOptions
-
-instance ToJSON SelectOption where
-  toJSON = genericToJSON aesonOptions
-
 -- | Response from @GET \/v1\/pages\/{page_id}\/markdown@
 --
 -- Contains the page content rendered as Notion-flavored enhanced markdown.
@@ -440,6 +311,30 @@
 instance ToJSON ReplaceContentRangeRequest where
   toJSON = genericToJSON aesonOptions
 
+-- | Response from the page property item endpoint.
+--
+-- The Notion API returns either a single property value (for most property types)
+-- or a paginated list of items (for title, rich_text, relation, and people properties
+-- that can have many items).
+data PropertyItemResponse
+  = -- | A single property value
+    SinglePropertyItem PropertyValue
+  | -- | A paginated list of property items. The 'Text' is the property type name.
+    PaginatedPropertyItems (ListOf PropertyValue) Text
+  deriving stock (Show)
+
+instance FromJSON PropertyItemResponse where
+  parseJSON = \case
+    Object o -> do
+      -- Check if this is a paginated response (has "results" key) or single item
+      if KeyMap.member "results" o
+        then do
+          listOf <- Aeson.parseJSON (Object o)
+          propType <- o .: "property_item" >>= (.: "type")
+          pure $ PaginatedPropertyItems listOf propType
+        else SinglePropertyItem <$> Aeson.parseJSON (Object o)
+    _ -> fail "Expected object for PropertyItemResponse"
+
 -- | Servant API
 type API =
   "pages"
@@ -450,6 +345,12 @@
            :<|> Capture "page_id" PageID
            :> ReqBody '[JSON] UpdatePage
            :> Patch '[JSON] PageObject
+           :<|> Capture "page_id" PageID
+           :> "properties"
+           :> Capture "property_id" Text
+           :> QueryParam "start_cursor" Text
+           :> QueryParam "page_size" Natural
+           :> Get '[JSON] PropertyItemResponse
            :<|> Capture "page_id" PageID
            :> "markdown"
            :> QueryParam "include_transcript" Bool
diff --git a/src/Notion/V1/Pagination.hs b/src/Notion/V1/Pagination.hs
--- a/src/Notion/V1/Pagination.hs
+++ b/src/Notion/V1/Pagination.hs
@@ -3,10 +3,17 @@
   ( -- * Pagination types
     PaginationParams (..),
     defaultPaginationParams,
+
+    -- * Auto-pagination
+    paginateAll,
+    paginateCollect,
+    PaginationResult (..),
   )
 where
 
+import Data.Vector qualified as Vector
 import Notion.Prelude
+import Notion.V1.ListOf (ListOf (..))
 
 -- | Pagination parameters for Notion API requests
 data PaginationParams = PaginationParams
@@ -25,3 +32,47 @@
     { pageSize = Nothing,
       startCursor = Nothing
     }
+
+-- | Result of auto-pagination, including all collected results and page count.
+data PaginationResult a = PaginationResult
+  { allResults :: Vector a,
+    totalPages :: Natural
+  }
+  deriving stock (Show)
+
+-- | Automatically paginate through all results by following cursors.
+--
+-- The callback receives an optional cursor ('Nothing' for the first page)
+-- and returns a paginated response. The function calls the callback
+-- repeatedly until 'hasMore' is 'False' or 'nextCursor' is 'Nothing',
+-- collecting all results into a single 'Vector'.
+--
+-- Example:
+--
+-- @
+-- allPages <- paginateAll $ \\cursor ->
+--   queryDataSource methods dsId QueryDataSource
+--     { filter = Nothing
+--     , sorts = Nothing
+--     , startCursor = cursor
+--     , pageSize = Just 100
+--     , inTrash = Nothing
+--     , filterProperties = Nothing
+--     }
+-- @
+paginateAll :: (Maybe Text -> IO (ListOf a)) -> IO (Vector a)
+paginateAll fetch = allResults <$> paginateCollect fetch
+
+-- | Like 'paginateAll' but also returns the number of pages fetched.
+paginateCollect :: (Maybe Text -> IO (ListOf a)) -> IO (PaginationResult a)
+paginateCollect fetch = go Nothing Vector.empty 0
+  where
+    go cursor acc pages = do
+      List {results, nextCursor, hasMore} <- fetch cursor
+      let acc' = acc <> results
+          pages' = pages + 1
+      if hasMore
+        then case nextCursor of
+          Just nc -> go (Just nc) acc' pages'
+          Nothing -> pure PaginationResult {allResults = acc', totalPages = pages'}
+        else pure PaginationResult {allResults = acc', totalPages = pages'}
diff --git a/src/Notion/V1/Properties.hs b/src/Notion/V1/Properties.hs
new file mode 100644
--- /dev/null
+++ b/src/Notion/V1/Properties.hs
@@ -0,0 +1,525 @@
+-- | Typed property schema definitions for Notion databases and data sources.
+--
+-- A property schema describes the "shape" of a database column — for example,
+-- a select property schema defines which options are available (names and colors),
+-- while a page's select property value is which option was chosen.
+--
+-- This module is used by both 'Notion.V1.Databases' and 'Notion.V1.DataSources'
+-- for typed @properties@ fields.
+module Notion.V1.Properties
+  ( -- * Property schema
+    PropertySchema (..),
+
+    -- * Supporting types
+    SelectColor (..),
+    SelectOption (..),
+    StatusGroup (..),
+    NumberFormat (..),
+    RollupFunction (..),
+    RelationType (..),
+  )
+where
+
+import Data.Aeson (object, (.:), (.:?), (.=))
+import Data.Aeson qualified as Aeson
+import Data.Aeson.Types (Parser)
+import Notion.Prelude
+import Notion.V1.Common (UUID)
+import Prelude hiding (id)
+
+-- | Colors available for select, multi-select, and status property options.
+--
+-- This is distinct from 'Notion.V1.Common.Color' which covers text annotation
+-- colors including background variants.
+data SelectColor
+  = DefaultColor
+  | Gray
+  | Brown
+  | Orange
+  | Yellow
+  | Green
+  | Blue
+  | Purple
+  | Pink
+  | Red
+  deriving stock (Eq, Show, Generic)
+
+instance FromJSON SelectColor where
+  parseJSON = Aeson.withText "SelectColor" $ \case
+    "default" -> pure DefaultColor
+    "gray" -> pure Gray
+    "brown" -> pure Brown
+    "orange" -> pure Orange
+    "yellow" -> pure Yellow
+    "green" -> pure Green
+    "blue" -> pure Blue
+    "purple" -> pure Purple
+    "pink" -> pure Pink
+    "red" -> pure Red
+    other -> fail $ "Unknown SelectColor: " <> unpack other
+
+instance ToJSON SelectColor where
+  toJSON DefaultColor = Aeson.String "default"
+  toJSON Gray = Aeson.String "gray"
+  toJSON Brown = Aeson.String "brown"
+  toJSON Orange = Aeson.String "orange"
+  toJSON Yellow = Aeson.String "yellow"
+  toJSON Green = Aeson.String "green"
+  toJSON Blue = Aeson.String "blue"
+  toJSON Purple = Aeson.String "purple"
+  toJSON Pink = Aeson.String "pink"
+  toJSON Red = Aeson.String "red"
+
+-- | A select or multi-select option in a property schema.
+data SelectOption = SelectOption
+  { id :: Maybe Text,
+    name :: Text,
+    color :: Maybe SelectColor
+  }
+  deriving stock (Eq, Show, Generic)
+
+instance FromJSON SelectOption where
+  parseJSON = genericParseJSON aesonOptions
+
+instance ToJSON SelectOption where
+  toJSON = genericToJSON aesonOptions
+
+-- | A status group in a status property schema.
+data StatusGroup = StatusGroup
+  { id :: Maybe Text,
+    name :: Text,
+    color :: Maybe SelectColor,
+    optionIds :: Vector Text
+  }
+  deriving stock (Eq, Show, Generic)
+
+instance FromJSON StatusGroup where
+  parseJSON = genericParseJSON aesonOptions
+
+instance ToJSON StatusGroup where
+  toJSON = genericToJSON aesonOptions
+
+-- | Number format for number property schemas.
+data NumberFormat
+  = NumberPlain
+  | NumberWithCommas
+  | Percent
+  | Dollar
+  | CanadianDollar
+  | Euro
+  | Pound
+  | Yen
+  | Ruble
+  | Rupee
+  | Won
+  | Yuan
+  | Real
+  | Lira
+  | Rupiah
+  | Franc
+  | HongKongDollar
+  | NewZealandDollar
+  | Krona
+  | NorwegianKrone
+  | MexicanPeso
+  | Rand
+  | NewTaiwanDollar
+  | DanishKrone
+  | Zloty
+  | Baht
+  | Forint
+  | Koruna
+  | Shekel
+  | ChileanPeso
+  | PhilippinePeso
+  | Dirham
+  | ColombianPeso
+  | Riyal
+  | Ringgit
+  | Leu
+  | ArgentinePeso
+  | UruguayanPeso
+  | SingaporeDollar
+  deriving stock (Eq, Show, Generic)
+
+instance FromJSON NumberFormat where
+  parseJSON = Aeson.withText "NumberFormat" $ \case
+    "number" -> pure NumberPlain
+    "number_with_commas" -> pure NumberWithCommas
+    "percent" -> pure Percent
+    "dollar" -> pure Dollar
+    "canadian_dollar" -> pure CanadianDollar
+    "euro" -> pure Euro
+    "pound" -> pure Pound
+    "yen" -> pure Yen
+    "ruble" -> pure Ruble
+    "rupee" -> pure Rupee
+    "won" -> pure Won
+    "yuan" -> pure Yuan
+    "real" -> pure Real
+    "lira" -> pure Lira
+    "rupiah" -> pure Rupiah
+    "franc" -> pure Franc
+    "hong_kong_dollar" -> pure HongKongDollar
+    "new_zealand_dollar" -> pure NewZealandDollar
+    "krona" -> pure Krona
+    "norwegian_krone" -> pure NorwegianKrone
+    "mexican_peso" -> pure MexicanPeso
+    "rand" -> pure Rand
+    "new_taiwan_dollar" -> pure NewTaiwanDollar
+    "danish_krone" -> pure DanishKrone
+    "zloty" -> pure Zloty
+    "baht" -> pure Baht
+    "forint" -> pure Forint
+    "koruna" -> pure Koruna
+    "shekel" -> pure Shekel
+    "chilean_peso" -> pure ChileanPeso
+    "philippine_peso" -> pure PhilippinePeso
+    "dirham" -> pure Dirham
+    "colombian_peso" -> pure ColombianPeso
+    "riyal" -> pure Riyal
+    "ringgit" -> pure Ringgit
+    "leu" -> pure Leu
+    "argentine_peso" -> pure ArgentinePeso
+    "uruguayan_peso" -> pure UruguayanPeso
+    "singapore_dollar" -> pure SingaporeDollar
+    other -> fail $ "Unknown NumberFormat: " <> unpack other
+
+instance ToJSON NumberFormat where
+  toJSON NumberPlain = Aeson.String "number"
+  toJSON NumberWithCommas = Aeson.String "number_with_commas"
+  toJSON Percent = Aeson.String "percent"
+  toJSON Dollar = Aeson.String "dollar"
+  toJSON CanadianDollar = Aeson.String "canadian_dollar"
+  toJSON Euro = Aeson.String "euro"
+  toJSON Pound = Aeson.String "pound"
+  toJSON Yen = Aeson.String "yen"
+  toJSON Ruble = Aeson.String "ruble"
+  toJSON Rupee = Aeson.String "rupee"
+  toJSON Won = Aeson.String "won"
+  toJSON Yuan = Aeson.String "yuan"
+  toJSON Real = Aeson.String "real"
+  toJSON Lira = Aeson.String "lira"
+  toJSON Rupiah = Aeson.String "rupiah"
+  toJSON Franc = Aeson.String "franc"
+  toJSON HongKongDollar = Aeson.String "hong_kong_dollar"
+  toJSON NewZealandDollar = Aeson.String "new_zealand_dollar"
+  toJSON Krona = Aeson.String "krona"
+  toJSON NorwegianKrone = Aeson.String "norwegian_krone"
+  toJSON MexicanPeso = Aeson.String "mexican_peso"
+  toJSON Rand = Aeson.String "rand"
+  toJSON NewTaiwanDollar = Aeson.String "new_taiwan_dollar"
+  toJSON DanishKrone = Aeson.String "danish_krone"
+  toJSON Zloty = Aeson.String "zloty"
+  toJSON Baht = Aeson.String "baht"
+  toJSON Forint = Aeson.String "forint"
+  toJSON Koruna = Aeson.String "koruna"
+  toJSON Shekel = Aeson.String "shekel"
+  toJSON ChileanPeso = Aeson.String "chilean_peso"
+  toJSON PhilippinePeso = Aeson.String "philippine_peso"
+  toJSON Dirham = Aeson.String "dirham"
+  toJSON ColombianPeso = Aeson.String "colombian_peso"
+  toJSON Riyal = Aeson.String "riyal"
+  toJSON Ringgit = Aeson.String "ringgit"
+  toJSON Leu = Aeson.String "leu"
+  toJSON ArgentinePeso = Aeson.String "argentine_peso"
+  toJSON UruguayanPeso = Aeson.String "uruguayan_peso"
+  toJSON SingaporeDollar = Aeson.String "singapore_dollar"
+
+-- | Rollup aggregation function.
+data RollupFunction
+  = CountAll
+  | CountValues
+  | CountUniqueValues
+  | CountEmpty
+  | CountNotEmpty
+  | PercentEmpty
+  | PercentNotEmpty
+  | Sum
+  | Average
+  | Median
+  | Min
+  | Max
+  | Range
+  | ShowOriginal
+  | Checked
+  | Unchecked
+  | PercentChecked
+  | PercentUnchecked
+  | DateRange
+  | EarliestDate
+  | LatestDate
+  | ShowUnique
+  | Count
+  | Empty
+  | NotEmpty
+  deriving stock (Eq, Show, Generic)
+
+instance FromJSON RollupFunction where
+  parseJSON = Aeson.withText "RollupFunction" $ \case
+    "count_all" -> pure CountAll
+    "count_values" -> pure CountValues
+    "count_unique_values" -> pure CountUniqueValues
+    "count_empty" -> pure CountEmpty
+    "count_not_empty" -> pure CountNotEmpty
+    "percent_empty" -> pure PercentEmpty
+    "percent_not_empty" -> pure PercentNotEmpty
+    "sum" -> pure Sum
+    "average" -> pure Average
+    "median" -> pure Median
+    "min" -> pure Min
+    "max" -> pure Max
+    "range" -> pure Range
+    "show_original" -> pure ShowOriginal
+    "checked" -> pure Checked
+    "unchecked" -> pure Unchecked
+    "percent_checked" -> pure PercentChecked
+    "percent_unchecked" -> pure PercentUnchecked
+    "date_range" -> pure DateRange
+    "earliest_date" -> pure EarliestDate
+    "latest_date" -> pure LatestDate
+    "show_unique" -> pure ShowUnique
+    "count" -> pure Count
+    "empty" -> pure Empty
+    "not_empty" -> pure NotEmpty
+    other -> fail $ "Unknown RollupFunction: " <> unpack other
+
+instance ToJSON RollupFunction where
+  toJSON CountAll = Aeson.String "count_all"
+  toJSON CountValues = Aeson.String "count_values"
+  toJSON CountUniqueValues = Aeson.String "count_unique_values"
+  toJSON CountEmpty = Aeson.String "count_empty"
+  toJSON CountNotEmpty = Aeson.String "count_not_empty"
+  toJSON PercentEmpty = Aeson.String "percent_empty"
+  toJSON PercentNotEmpty = Aeson.String "percent_not_empty"
+  toJSON Sum = Aeson.String "sum"
+  toJSON Average = Aeson.String "average"
+  toJSON Median = Aeson.String "median"
+  toJSON Min = Aeson.String "min"
+  toJSON Max = Aeson.String "max"
+  toJSON Range = Aeson.String "range"
+  toJSON ShowOriginal = Aeson.String "show_original"
+  toJSON Checked = Aeson.String "checked"
+  toJSON Unchecked = Aeson.String "unchecked"
+  toJSON PercentChecked = Aeson.String "percent_checked"
+  toJSON PercentUnchecked = Aeson.String "percent_unchecked"
+  toJSON DateRange = Aeson.String "date_range"
+  toJSON EarliestDate = Aeson.String "earliest_date"
+  toJSON LatestDate = Aeson.String "latest_date"
+  toJSON ShowUnique = Aeson.String "show_unique"
+  toJSON Count = Aeson.String "count"
+  toJSON Empty = Aeson.String "empty"
+  toJSON NotEmpty = Aeson.String "not_empty"
+
+-- | Relation property type configuration.
+data RelationType
+  = SingleProperty
+  | DualProperty
+      { syncedPropertyId :: Text,
+        syncedPropertyName :: Text
+      }
+  deriving stock (Eq, Show, Generic)
+
+instance FromJSON RelationType where
+  parseJSON = \case
+    Object o -> do
+      relType <- o .: "type"
+      case relType of
+        "single_property" -> pure SingleProperty
+        "dual_property" -> do
+          dp <- o .: "dual_property"
+          syncedPropertyId <- dp .: "synced_property_id"
+          syncedPropertyName <- dp .: "synced_property_name"
+          pure DualProperty {..}
+        other -> fail $ "Unknown RelationType: " <> unpack other
+    _ -> fail "Expected object for RelationType"
+
+instance ToJSON RelationType where
+  toJSON SingleProperty =
+    object ["type" .= ("single_property" :: Text)]
+  toJSON DualProperty {..} =
+    object
+      [ "type" .= ("dual_property" :: Text),
+        "dual_property"
+          .= object
+            [ "synced_property_id" .= syncedPropertyId,
+              "synced_property_name" .= syncedPropertyName
+            ]
+      ]
+
+-- | Typed property schema for a database or data source property.
+--
+-- Each constructor carries the common envelope fields (@schemaId@, @schemaName@)
+-- plus any type-specific configuration. The JSON representation uses a @type@
+-- discriminator with the configuration nested under a key matching the type name.
+data PropertySchema
+  = TitleSchema {schemaId :: Text, schemaName :: Text}
+  | RichTextSchema {schemaId :: Text, schemaName :: Text}
+  | NumberSchema {schemaId :: Text, schemaName :: Text, numberFormat :: NumberFormat}
+  | SelectSchema {schemaId :: Text, schemaName :: Text, selectOptions :: Vector SelectOption}
+  | MultiSelectSchema {schemaId :: Text, schemaName :: Text, multiSelectOptions :: Vector SelectOption}
+  | DateSchema {schemaId :: Text, schemaName :: Text}
+  | PeopleSchema {schemaId :: Text, schemaName :: Text}
+  | FilesSchema {schemaId :: Text, schemaName :: Text}
+  | CheckboxSchema {schemaId :: Text, schemaName :: Text}
+  | UrlSchema {schemaId :: Text, schemaName :: Text}
+  | EmailSchema {schemaId :: Text, schemaName :: Text}
+  | PhoneNumberSchema {schemaId :: Text, schemaName :: Text}
+  | FormulaSchema {schemaId :: Text, schemaName :: Text, formulaExpression :: Text}
+  | RelationSchema {schemaId :: Text, schemaName :: Text, relationDataSourceId :: UUID, relationType :: RelationType}
+  | RollupSchema
+      { schemaId :: Text,
+        schemaName :: Text,
+        rollupFunction :: RollupFunction,
+        rollupRelationPropertyName :: Maybe Text,
+        rollupRelationPropertyId :: Maybe Text,
+        rollupPropertyName :: Maybe Text,
+        rollupPropertyId :: Maybe Text
+      }
+  | CreatedTimeSchema {schemaId :: Text, schemaName :: Text}
+  | CreatedBySchema {schemaId :: Text, schemaName :: Text}
+  | LastEditedTimeSchema {schemaId :: Text, schemaName :: Text}
+  | LastEditedBySchema {schemaId :: Text, schemaName :: Text}
+  | StatusSchema {schemaId :: Text, schemaName :: Text, statusOptions :: Vector SelectOption, statusGroups :: Vector StatusGroup}
+  | UniqueIdSchema {schemaId :: Text, schemaName :: Text, uniqueIdPrefix :: Maybe Text}
+  | PlaceSchema {schemaId :: Text, schemaName :: Text}
+  | ButtonSchema {schemaId :: Text, schemaName :: Text}
+  | VerificationSchema {schemaId :: Text, schemaName :: Text}
+  deriving stock (Eq, Show, Generic)
+
+instance FromJSON PropertySchema where
+  parseJSON = \case
+    Object o -> do
+      sid <- o .: "id"
+      sname <- o .: "name"
+      propType <- o .: "type"
+      parseByType sid sname propType o
+    _ -> fail "Expected object for PropertySchema"
+    where
+      parseByType :: Text -> Text -> Text -> Aeson.Object -> Parser PropertySchema
+      parseByType sid sname = \case
+        "title" -> \_ -> pure TitleSchema {schemaId = sid, schemaName = sname}
+        "rich_text" -> \_ -> pure RichTextSchema {schemaId = sid, schemaName = sname}
+        "number" -> \o -> do
+          cfg <- o .: "number"
+          fmt <- cfg .: "format"
+          pure NumberSchema {schemaId = sid, schemaName = sname, numberFormat = fmt}
+        "select" -> \o -> do
+          cfg <- o .: "select"
+          opts <- cfg .: "options"
+          pure SelectSchema {schemaId = sid, schemaName = sname, selectOptions = opts}
+        "multi_select" -> \o -> do
+          cfg <- o .: "multi_select"
+          opts <- cfg .: "options"
+          pure MultiSelectSchema {schemaId = sid, schemaName = sname, multiSelectOptions = opts}
+        "date" -> \_ -> pure DateSchema {schemaId = sid, schemaName = sname}
+        "people" -> \_ -> pure PeopleSchema {schemaId = sid, schemaName = sname}
+        "files" -> \_ -> pure FilesSchema {schemaId = sid, schemaName = sname}
+        "checkbox" -> \_ -> pure CheckboxSchema {schemaId = sid, schemaName = sname}
+        "url" -> \_ -> pure UrlSchema {schemaId = sid, schemaName = sname}
+        "email" -> \_ -> pure EmailSchema {schemaId = sid, schemaName = sname}
+        "phone_number" -> \_ -> pure PhoneNumberSchema {schemaId = sid, schemaName = sname}
+        "formula" -> \o -> do
+          cfg <- o .: "formula"
+          expr <- cfg .: "expression"
+          pure FormulaSchema {schemaId = sid, schemaName = sname, formulaExpression = expr}
+        "relation" -> \o -> do
+          cfg <- o .: "relation"
+          dsId <- cfg .: "data_source_id"
+          relType <- Aeson.parseJSON (Object cfg)
+          pure RelationSchema {schemaId = sid, schemaName = sname, relationDataSourceId = dsId, relationType = relType}
+        "rollup" -> \o -> do
+          cfg <- o .: "rollup"
+          fn <- cfg .: "function"
+          relPropName <- cfg .:? "relation_property_name"
+          relPropId <- cfg .:? "relation_property_id"
+          propName <- cfg .:? "rollup_property_name"
+          propId <- cfg .:? "rollup_property_id"
+          pure
+            RollupSchema
+              { schemaId = sid,
+                schemaName = sname,
+                rollupFunction = fn,
+                rollupRelationPropertyName = relPropName,
+                rollupRelationPropertyId = relPropId,
+                rollupPropertyName = propName,
+                rollupPropertyId = propId
+              }
+        "created_time" -> \_ -> pure CreatedTimeSchema {schemaId = sid, schemaName = sname}
+        "created_by" -> \_ -> pure CreatedBySchema {schemaId = sid, schemaName = sname}
+        "last_edited_time" -> \_ -> pure LastEditedTimeSchema {schemaId = sid, schemaName = sname}
+        "last_edited_by" -> \_ -> pure LastEditedBySchema {schemaId = sid, schemaName = sname}
+        "status" -> \o -> do
+          cfg <- o .: "status"
+          opts <- cfg .: "options"
+          grps <- cfg .: "groups"
+          pure StatusSchema {schemaId = sid, schemaName = sname, statusOptions = opts, statusGroups = grps}
+        "unique_id" -> \o -> do
+          cfg <- o .: "unique_id"
+          prefix <- cfg .:? "prefix"
+          pure UniqueIdSchema {schemaId = sid, schemaName = sname, uniqueIdPrefix = prefix}
+        "place" -> \_ -> pure PlaceSchema {schemaId = sid, schemaName = sname}
+        "button" -> \_ -> pure ButtonSchema {schemaId = sid, schemaName = sname}
+        "verification" -> \_ -> pure VerificationSchema {schemaId = sid, schemaName = sname}
+        other -> \_ -> fail $ "Unknown property type: " <> unpack other
+
+instance ToJSON PropertySchema where
+  toJSON schema =
+    let (sid, sname, typeName, typeConfig) = schemaFields schema
+     in object $
+          [ "id" .= sid,
+            "name" .= sname,
+            "type" .= typeName
+          ]
+            <> [typeName .= typeConfig]
+
+schemaFields :: PropertySchema -> (Text, Text, Aeson.Key, Value)
+schemaFields = \case
+  TitleSchema {..} -> (schemaId, schemaName, "title", object [])
+  RichTextSchema {..} -> (schemaId, schemaName, "rich_text", object [])
+  NumberSchema {..} -> (schemaId, schemaName, "number", object ["format" .= numberFormat])
+  SelectSchema {..} -> (schemaId, schemaName, "select", object ["options" .= selectOptions])
+  MultiSelectSchema {..} -> (schemaId, schemaName, "multi_select", object ["options" .= multiSelectOptions])
+  DateSchema {..} -> (schemaId, schemaName, "date", object [])
+  PeopleSchema {..} -> (schemaId, schemaName, "people", object [])
+  FilesSchema {..} -> (schemaId, schemaName, "files", object [])
+  CheckboxSchema {..} -> (schemaId, schemaName, "checkbox", object [])
+  UrlSchema {..} -> (schemaId, schemaName, "url", object [])
+  EmailSchema {..} -> (schemaId, schemaName, "email", object [])
+  PhoneNumberSchema {..} -> (schemaId, schemaName, "phone_number", object [])
+  FormulaSchema {..} -> (schemaId, schemaName, "formula", object ["expression" .= formulaExpression])
+  RelationSchema {..} ->
+    let relObj = case relationType of
+          SingleProperty -> object ["data_source_id" .= relationDataSourceId, "type" .= ("single_property" :: Text)]
+          DualProperty {..} ->
+            object
+              [ "data_source_id" .= relationDataSourceId,
+                "type" .= ("dual_property" :: Text),
+                "dual_property" .= object ["synced_property_id" .= syncedPropertyId, "synced_property_name" .= syncedPropertyName]
+              ]
+     in (schemaId, schemaName, "relation", relObj)
+  RollupSchema {..} ->
+    ( schemaId,
+      schemaName,
+      "rollup",
+      object $
+        ["function" .= rollupFunction]
+          <> maybe [] (\v -> ["relation_property_name" .= v]) rollupRelationPropertyName
+          <> maybe [] (\v -> ["relation_property_id" .= v]) rollupRelationPropertyId
+          <> maybe [] (\v -> ["rollup_property_name" .= v]) rollupPropertyName
+          <> maybe [] (\v -> ["rollup_property_id" .= v]) rollupPropertyId
+    )
+  CreatedTimeSchema {..} -> (schemaId, schemaName, "created_time", object [])
+  CreatedBySchema {..} -> (schemaId, schemaName, "created_by", object [])
+  LastEditedTimeSchema {..} -> (schemaId, schemaName, "last_edited_time", object [])
+  LastEditedBySchema {..} -> (schemaId, schemaName, "last_edited_by", object [])
+  StatusSchema {..} -> (schemaId, schemaName, "status", object ["options" .= statusOptions, "groups" .= statusGroups])
+  UniqueIdSchema {..} ->
+    ( schemaId,
+      schemaName,
+      "unique_id",
+      object $ maybe [] (\v -> ["prefix" .= v]) uniqueIdPrefix
+    )
+  PlaceSchema {..} -> (schemaId, schemaName, "place", object [])
+  ButtonSchema {..} -> (schemaId, schemaName, "button", object [])
+  VerificationSchema {..} -> (schemaId, schemaName, "verification", object [])
diff --git a/src/Notion/V1/PropertyValue.hs b/src/Notion/V1/PropertyValue.hs
new file mode 100644
--- /dev/null
+++ b/src/Notion/V1/PropertyValue.hs
@@ -0,0 +1,353 @@
+-- | Typed property values for Notion pages.
+--
+-- A property value represents the actual data stored in a page's property —
+-- for example, the selected option in a select property, or the text in a title.
+-- This is distinct from 'Notion.V1.Properties.PropertySchema' which describes
+-- the shape/configuration of the property column.
+--
+-- This module provides:
+--
+-- * A 'PropertyValue' sum type for reading page properties via pattern matching
+-- * Smart constructors ('titleValue', 'selectValue', etc.) for writing properties
+-- * Supporting types ('SelectOptionValue', 'FileValue', 'RelationRef', etc.)
+module Notion.V1.PropertyValue
+  ( -- * Property value
+    PropertyValue (..),
+
+    -- * Supporting types
+    SelectOptionValue (..),
+    FileValue (..),
+    RelationRef (..),
+    FormulaResult (..),
+    RollupResult (..),
+    UniqueIdResult (..),
+    VerificationResult (..),
+
+    -- * Smart constructors
+    titleValue,
+    richTextValue,
+    numberValue,
+    selectValue,
+    multiSelectValue,
+    dateValue,
+    checkboxValue,
+    urlValue,
+    emailValue,
+    phoneNumberValue,
+    relationValue,
+    statusValue,
+    peopleValue,
+    filesValue,
+  )
+where
+
+import Data.Aeson ((.:), (.:?), (.=))
+import Data.Aeson qualified as Aeson
+import Data.Aeson.Key qualified as Key
+import Data.Scientific (Scientific)
+import Data.Vector qualified as Vector
+import Notion.Prelude
+import Notion.V1.Common (ExternalFile (..), File, UUID (..))
+import Notion.V1.Properties (RollupFunction)
+import Notion.V1.RichText (Date (..), RichText)
+import Notion.V1.Users (UserReference (..))
+import Prelude hiding (id)
+
+-- | A typed property value from a Notion page.
+--
+-- Each constructor carries the property's schema ID as its first 'Text' field.
+-- When reading from the API this is the property's internal ID (e.g., @\"abc\"@).
+-- When constructing values for writes, use @\"\"@ (the smart constructors do this).
+--
+-- Read-only variants ('FormulaValue', 'RollupValue', 'UniqueIdValue',
+-- 'CreatedTimeValue', 'CreatedByValue', 'LastEditedTimeValue', 'LastEditedByValue',
+-- 'VerificationValue') only appear in API responses.
+data PropertyValue
+  = TitleValue Text (Vector RichText)
+  | RichTextValue Text (Vector RichText)
+  | NumberValue Text (Maybe Scientific)
+  | SelectValue Text (Maybe SelectOptionValue)
+  | MultiSelectValue Text (Vector SelectOptionValue)
+  | DateValue Text (Maybe Date)
+  | PeopleValue Text (Vector UserReference)
+  | FilesValue Text (Vector FileValue)
+  | CheckboxValue Text Bool
+  | UrlValue Text (Maybe Text)
+  | EmailValue Text (Maybe Text)
+  | PhoneNumberValue Text (Maybe Text)
+  | FormulaValue Text FormulaResult
+  | RelationValue Text (Vector RelationRef)
+  | RollupValue Text RollupResult
+  | CreatedTimeValue Text Text
+  | CreatedByValue Text UserReference
+  | LastEditedTimeValue Text Text
+  | LastEditedByValue Text UserReference
+  | StatusValue Text (Maybe SelectOptionValue)
+  | UniqueIdValue Text UniqueIdResult
+  | PlaceValue Text (Maybe Value)
+  | ButtonValue Text (Maybe Value)
+  | VerificationValue Text (Maybe VerificationResult)
+  deriving stock (Show)
+
+instance FromJSON PropertyValue where
+  parseJSON = \case
+    Object o -> do
+      pid <- o .: "id"
+      propType <- o .: "type"
+      let key = Key.fromText propType
+      case propType of
+        "title" -> TitleValue pid <$> o .: key
+        "rich_text" -> RichTextValue pid <$> o .: key
+        "number" -> NumberValue pid <$> o .: key
+        "select" -> SelectValue pid <$> o .:? key
+        "multi_select" -> MultiSelectValue pid <$> o .: key
+        "date" -> DateValue pid <$> o .:? key
+        "people" -> PeopleValue pid <$> o .: key
+        "files" -> FilesValue pid <$> o .: key
+        "checkbox" -> CheckboxValue pid <$> o .: key
+        "url" -> UrlValue pid <$> o .:? key
+        "email" -> EmailValue pid <$> o .:? key
+        "phone_number" -> PhoneNumberValue pid <$> o .:? key
+        "formula" -> FormulaValue pid <$> o .: key
+        "relation" -> RelationValue pid <$> o .: key
+        "rollup" -> RollupValue pid <$> o .: key
+        "created_time" -> CreatedTimeValue pid <$> o .: key
+        "created_by" -> CreatedByValue pid <$> o .: key
+        "last_edited_time" -> LastEditedTimeValue pid <$> o .: key
+        "last_edited_by" -> LastEditedByValue pid <$> o .: key
+        "status" -> StatusValue pid <$> o .:? key
+        "unique_id" -> UniqueIdValue pid <$> o .: key
+        "place" -> PlaceValue pid <$> o .:? key
+        "button" -> ButtonValue pid <$> o .:? key
+        "verification" -> VerificationValue pid <$> o .:? key
+        other -> fail $ "Unknown property value type: " <> unpack other
+    _ -> fail "Expected object for PropertyValue"
+
+instance ToJSON PropertyValue where
+  toJSON = \case
+    TitleValue _ v -> Aeson.object ["title" .= v]
+    RichTextValue _ v -> Aeson.object ["rich_text" .= v]
+    NumberValue _ v -> Aeson.object ["number" .= v]
+    SelectValue _ v -> Aeson.object ["select" .= v]
+    MultiSelectValue _ v -> Aeson.object ["multi_select" .= v]
+    DateValue _ v -> Aeson.object ["date" .= v]
+    PeopleValue _ v -> Aeson.object ["people" .= v]
+    FilesValue _ v -> Aeson.object ["files" .= v]
+    CheckboxValue _ v -> Aeson.object ["checkbox" .= v]
+    UrlValue _ v -> Aeson.object ["url" .= v]
+    EmailValue _ v -> Aeson.object ["email" .= v]
+    PhoneNumberValue _ v -> Aeson.object ["phone_number" .= v]
+    FormulaValue _ v -> Aeson.object ["formula" .= v]
+    RelationValue _ v -> Aeson.object ["relation" .= v]
+    RollupValue _ v -> Aeson.object ["rollup" .= v]
+    CreatedTimeValue _ v -> Aeson.object ["created_time" .= v]
+    CreatedByValue _ v -> Aeson.object ["created_by" .= v]
+    LastEditedTimeValue _ v -> Aeson.object ["last_edited_time" .= v]
+    LastEditedByValue _ v -> Aeson.object ["last_edited_by" .= v]
+    StatusValue _ v -> Aeson.object ["status" .= v]
+    UniqueIdValue _ v -> Aeson.object ["unique_id" .= v]
+    PlaceValue _ v -> Aeson.object ["place" .= v]
+    ButtonValue _ v -> Aeson.object ["button" .= v]
+    VerificationValue _ v -> Aeson.object ["verification" .= v]
+
+-- ---------------------------------------------------------------------------
+-- Supporting types
+-- ---------------------------------------------------------------------------
+
+-- | A select/multi-select/status option value as it appears in page properties.
+--
+-- Note: 'color' is @Maybe Text@ (not 'SelectColor') because the API returns
+-- color names as strings in property values, and the set may differ from schema colors.
+data SelectOptionValue = SelectOptionValue
+  { id :: Maybe Text,
+    name :: Text,
+    color :: Maybe Text
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON SelectOptionValue where
+  parseJSON = genericParseJSON aesonOptions
+
+instance ToJSON SelectOptionValue where
+  toJSON = genericToJSON aesonOptions
+
+-- | A file value in a files property. Can be an internal (Notion-hosted) file
+-- or an external URL.
+data FileValue
+  = InternalFileValue {name :: Text, file :: File}
+  | ExternalFileValue {name :: Text, external :: ExternalFile}
+  deriving stock (Show)
+
+instance FromJSON FileValue where
+  parseJSON = \case
+    Object o -> do
+      fileType <- o .: "type"
+      n <- o .: "name"
+      case fileType of
+        "file" -> InternalFileValue n <$> o .: "file"
+        "external" -> ExternalFileValue n <$> o .: "external"
+        other -> fail $ "Unknown file value type: " <> unpack other
+    _ -> fail "Expected object for FileValue"
+
+instance ToJSON FileValue where
+  toJSON (InternalFileValue n f) =
+    Aeson.object ["type" .= ("file" :: Text), "name" .= n, "file" .= f]
+  toJSON (ExternalFileValue n e) =
+    Aeson.object ["type" .= ("external" :: Text), "name" .= n, "external" .= e]
+
+-- | A relation reference (just a page ID).
+newtype RelationRef = RelationRef {id :: UUID}
+  deriving stock (Show)
+
+instance FromJSON RelationRef where
+  parseJSON = \case
+    Object o -> RelationRef <$> o .: "id"
+    _ -> fail "Expected object for RelationRef"
+
+instance ToJSON RelationRef where
+  toJSON (RelationRef rid) = Aeson.object ["id" .= rid]
+
+-- | The result of a formula property (read-only).
+data FormulaResult
+  = FormulaStringResult (Maybe Text)
+  | FormulaNumberResult (Maybe Scientific)
+  | FormulaBooleanResult (Maybe Bool)
+  | FormulaDateResult (Maybe Date)
+  deriving stock (Show)
+
+instance FromJSON FormulaResult where
+  parseJSON = \case
+    Object o -> do
+      formulaType <- o .: "type"
+      case formulaType of
+        "string" -> FormulaStringResult <$> o .:? "string"
+        "number" -> FormulaNumberResult <$> o .:? "number"
+        "boolean" -> FormulaBooleanResult <$> o .:? "boolean"
+        "date" -> FormulaDateResult <$> o .:? "date"
+        other -> fail $ "Unknown formula result type: " <> unpack other
+    _ -> fail "Expected object for FormulaResult"
+
+instance ToJSON FormulaResult where
+  toJSON = \case
+    FormulaStringResult v -> Aeson.object ["type" .= ("string" :: Text), "string" .= v]
+    FormulaNumberResult v -> Aeson.object ["type" .= ("number" :: Text), "number" .= v]
+    FormulaBooleanResult v -> Aeson.object ["type" .= ("boolean" :: Text), "boolean" .= v]
+    FormulaDateResult v -> Aeson.object ["type" .= ("date" :: Text), "date" .= v]
+
+-- | The result of a rollup property (read-only).
+data RollupResult
+  = RollupNumberResult (Maybe Scientific) RollupFunction
+  | RollupDateResult (Maybe Date) RollupFunction
+  | RollupArrayResult (Vector Value) RollupFunction
+  | RollupIncompleteResult RollupFunction
+  | RollupUnsupportedResult RollupFunction
+  deriving stock (Show)
+
+instance FromJSON RollupResult where
+  parseJSON = \case
+    Object o -> do
+      rollupType <- o .: "type"
+      fn <- o .: "function"
+      case rollupType of
+        "number" -> RollupNumberResult <$> o .:? "number" <*> pure fn
+        "date" -> RollupDateResult <$> o .:? "date" <*> pure fn
+        "array" -> RollupArrayResult <$> o .: "array" <*> pure fn
+        "incomplete" -> pure $ RollupIncompleteResult fn
+        "unsupported" -> pure $ RollupUnsupportedResult fn
+        other -> fail $ "Unknown rollup result type: " <> unpack other
+    _ -> fail "Expected object for RollupResult"
+
+instance ToJSON RollupResult where
+  toJSON = \case
+    RollupNumberResult v fn -> Aeson.object ["type" .= ("number" :: Text), "number" .= v, "function" .= fn]
+    RollupDateResult v fn -> Aeson.object ["type" .= ("date" :: Text), "date" .= v, "function" .= fn]
+    RollupArrayResult v fn -> Aeson.object ["type" .= ("array" :: Text), "array" .= v, "function" .= fn]
+    RollupIncompleteResult fn -> Aeson.object ["type" .= ("incomplete" :: Text), "function" .= fn]
+    RollupUnsupportedResult fn -> Aeson.object ["type" .= ("unsupported" :: Text), "function" .= fn]
+
+-- | Unique ID property value (read-only).
+data UniqueIdResult = UniqueIdResult
+  { number :: Natural,
+    prefix :: Maybe Text
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON UniqueIdResult where
+  parseJSON = genericParseJSON aesonOptions
+
+instance ToJSON UniqueIdResult where
+  toJSON = genericToJSON aesonOptions
+
+-- | Verification property value (read-only).
+data VerificationResult = VerificationResult
+  { state :: Text,
+    verifiedBy :: Maybe UserReference,
+    date :: Maybe Date
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON VerificationResult where
+  parseJSON = genericParseJSON aesonOptions
+
+instance ToJSON VerificationResult where
+  toJSON = genericToJSON aesonOptions
+
+-- ---------------------------------------------------------------------------
+-- Smart constructors
+-- ---------------------------------------------------------------------------
+
+-- | Create a title property value.
+titleValue :: Vector RichText -> PropertyValue
+titleValue = TitleValue ""
+
+-- | Create a rich text property value.
+richTextValue :: Vector RichText -> PropertyValue
+richTextValue = RichTextValue ""
+
+-- | Create a number property value.
+numberValue :: Scientific -> PropertyValue
+numberValue n = NumberValue "" (Just n)
+
+-- | Create a select property value by option name.
+selectValue :: Text -> PropertyValue
+selectValue name = SelectValue "" (Just (SelectOptionValue Nothing name Nothing))
+
+-- | Create a multi-select property value from a list of option names.
+multiSelectValue :: [Text] -> PropertyValue
+multiSelectValue names = MultiSelectValue "" (Vector.fromList (map (\n -> SelectOptionValue Nothing n Nothing) names))
+
+-- | Create a date property value.
+dateValue :: Text -> Maybe Text -> PropertyValue
+dateValue start end = DateValue "" (Just (Date start end Nothing))
+
+-- | Create a checkbox property value.
+checkboxValue :: Bool -> PropertyValue
+checkboxValue = CheckboxValue ""
+
+-- | Create a URL property value.
+urlValue :: Text -> PropertyValue
+urlValue t = UrlValue "" (Just t)
+
+-- | Create an email property value.
+emailValue :: Text -> PropertyValue
+emailValue t = EmailValue "" (Just t)
+
+-- | Create a phone number property value.
+phoneNumberValue :: Text -> PropertyValue
+phoneNumberValue t = PhoneNumberValue "" (Just t)
+
+-- | Create a relation property value from a list of page IDs.
+relationValue :: [UUID] -> PropertyValue
+relationValue ids = RelationValue "" (Vector.fromList (map RelationRef ids))
+
+-- | Create a status property value by option name.
+statusValue :: Text -> PropertyValue
+statusValue name = StatusValue "" (Just (SelectOptionValue Nothing name Nothing))
+
+-- | Create a people property value from a list of user IDs.
+peopleValue :: [UUID] -> PropertyValue
+peopleValue ids = PeopleValue "" (Vector.fromList (map (\i -> UserReference i "user") ids))
+
+-- | Create a files property value from a list of external URLs.
+filesValue :: [Text] -> PropertyValue
+filesValue urls = FilesValue "" (Vector.fromList (map (\u -> ExternalFileValue "" (ExternalFile u)) urls))
diff --git a/tasty/Main.hs b/tasty/Main.hs
--- a/tasty/Main.hs
+++ b/tasty/Main.hs
@@ -3,7 +3,9 @@
 import Data.Aeson qualified as Aeson
 import Data.Aeson.Key qualified as Key
 import Data.Aeson.KeyMap qualified as KeyMap
+import Data.IORef (modifyIORef', newIORef, readIORef)
 import Data.Map qualified as Map
+import Data.Scientific (Scientific)
 import Data.Text qualified as Text
 import Data.Vector qualified as Vector
 import Notion.V1
@@ -17,15 +19,16 @@
 import Notion.V1.DataSources qualified as DataSources
 import Notion.V1.Databases (DataSource (..), DatabaseObject (..))
 import Notion.V1.Databases qualified as Databases
+import Notion.V1.Error (NotionError (..))
+import Notion.V1.Filter qualified as F
 import Notion.V1.ListOf (ListOf (..))
+import Notion.V1.ListOf qualified as ListOf
 import Notion.V1.Pages
   ( ContentUpdate (..),
     CreatePage (..),
     MovePage (..),
     PageMarkdown (..),
     PageObject (..),
-    PropertyValue (..),
-    PropertyValueType (..),
     ReplaceContentRequest (..),
     Template (..),
     UpdateContentRequest (..),
@@ -34,7 +37,10 @@
     mkCreatePage,
     mkUpdatePage,
   )
-import Notion.V1.RichText (Annotations (..), RichTextContent (..), TextContent (..), defaultAnnotations)
+import Notion.V1.Pagination (PaginationResult (..), paginateCollect)
+import Notion.V1.Properties qualified as Props
+import Notion.V1.PropertyValue qualified as PV
+import Notion.V1.RichText (Annotations (..), Date (..), RichText (..), RichTextContent (..), TextContent (..), defaultAnnotations)
 import Notion.V1.RichText qualified as RT
 import Notion.V1.Search (SearchRequest (..), SearchResult (..), dataSourceFilter, pageFilter, parseSearchResults)
 import Notion.V1.Users (UserObject (..))
@@ -150,6 +156,7 @@
       "Notion Client Tests"
       [ jsonParsingTests,
         jsonSerializationTests,
+        propertyValueTests,
         basicIntegration,
         markdownE2E,
         pageE2E,
@@ -186,11 +193,22 @@
           (Key.fromText headingType, Aeson.object [("rich_text", mkRichTextValue t)])
         ]
 
+-- | Helper to create a plain RichText from a string
+mkPlainRichText :: Text.Text -> RichText
+mkPlainRichText t =
+  RichText
+    { plainText = t,
+      href = Nothing,
+      annotations = defaultAnnotations,
+      type_ = "text",
+      content = TextContentWrapper (TextContent {content = t, link = Nothing})
+    }
+
 -- | Helper to create a simple test page under a parent page
 createTestPage :: Methods -> Text.Text -> Text.Text -> IO PageObject
 createTestPage Methods {createPage} parentPageId title = do
-  let titleProp = Aeson.object [("title", mkRichTextValue title)]
-      props = Map.fromList [("title", PropertyValue {type_ = Title, value = Just titleProp})]
+  let titleRt = mkPlainRichText title
+      props = Map.fromList [("title", PV.titleValue (Vector.singleton titleRt))]
       req = mkCreatePage (PageParent {pageId = UUID parentPageId}) props
   createPage req
 
@@ -229,6 +247,7 @@
     [ testCase "Parse BlockObject with in_trash" testParseBlockObject,
       testCase "Parse BlockObject with legacy archived field" testParseBlockObjectLegacy,
       testCase "Parse PageObject with in_trash" testParsePageObject,
+      testCase "Parse NotionError from JSON" testParseNotionError,
       testCase "Parse DatabaseObject with in_trash" testParseDatabaseObject,
       testCase "Parse DataSourceObject with in_trash" testParseDataSourceObject
     ]
@@ -320,6 +339,20 @@
     Right ds -> do
       assertEqual "inTrash should be Just False" (Just False) (Notion.V1.DataSources.inTrash ds)
 
+testParseNotionError :: Assertion
+testParseNotionError = do
+  let json =
+        "{\"object\":\"error\",\"status\":400,\"code\":\"validation_error\""
+          <> ",\"message\":\"The provided page ID is not a valid UUID.\""
+          <> ",\"details\":null}"
+  case Aeson.eitherDecode json of
+    Left err -> assertFailure $ "Failed to parse NotionError: " <> err
+    Right notionErr -> do
+      let NotionError {status = errStatus, code = errCode, message = errMessage} = notionErr
+      assertEqual "status" 400 errStatus
+      assertEqual "code" "validation_error" errCode
+      assertEqual "message" "The provided page ID is not a valid UUID." errMessage
+
 -- =====================================================================
 -- JSON Serialization Tests (unit tests, no API token needed)
 -- =====================================================================
@@ -344,7 +377,24 @@
       testCase "CreateView serialization" testSerializeCreateView,
       testCase "UpdateView omits Nothing fields" testSerializeUpdateView,
       testCase "CreatePage with markdown field" testSerializeCreatePageMarkdown,
-      testCase "UpdatePage with template and eraseContent" testSerializeUpdatePageTemplate
+      testCase "UpdatePage with template and eraseContent" testSerializeUpdatePageTemplate,
+      testCase "PropertySchema select round-trip" testPropertySchemaSelectRoundTrip,
+      testCase "PropertySchema number round-trip" testPropertySchemaNumberRoundTrip,
+      testCase "PropertySchema formula round-trip" testPropertySchemaFormulaRoundTrip,
+      testCase "PropertySchema relation dual round-trip" testPropertySchemaRelationRoundTrip,
+      testCase "PropertySchema status round-trip" testPropertySchemaStatusRoundTrip,
+      testCase "NumberFormat round-trip" testNumberFormatRoundTrip,
+      testCase "RollupFunction round-trip" testRollupFunctionRoundTrip,
+      testCase "Filter: property title contains" testFilterPropertyTitle,
+      testCase "Filter: compound And" testFilterCompoundAnd,
+      testCase "Filter: timestamp created_time" testFilterTimestamp,
+      testCase "Filter: number greater_than" testFilterNumber,
+      testCase "Filter: date next_week" testFilterDateRelative,
+      testCase "Filter: formula string contains" testFilterFormula,
+      testCase "Sort: property ascending" testSortProperty,
+      testCase "Sort: timestamp descending" testSortTimestamp,
+      testCase "UpdateDataSource nullable property deletion" testSerializeNullablePropertyDeletion,
+      testCase "paginateAll collects all pages" testPaginateAll
     ]
 
 testSerializeAppendNoPosition :: Assertion
@@ -897,7 +947,7 @@
 testQueryDataSource :: Methods -> Text.Text -> Assertion
 testQueryDataSource methods@Methods {queryDataSource} dbIdText = do
   dsId <- getFirstDataSourceId methods dbIdText
-  let queryReq = DataSources.QueryDataSource {filter = Nothing, sorts = Nothing, startCursor = Nothing, pageSize = Just 5, inTrash = Nothing}
+  let queryReq = DataSources.QueryDataSource {filter = Nothing, sorts = Nothing, startCursor = Nothing, pageSize = Just 5, inTrash = Nothing, filterProperties = Nothing}
   result <- queryDataSource dsId queryReq
   -- Just verify the endpoint responds and returns valid structure
   assertBool "Query should return results list" (hasMore result || Vector.null (results result) || not (Vector.null (results result)))
@@ -907,8 +957,8 @@
   dsId <- getFirstDataSourceId methods dbIdText
 
   -- Create a page in the database's data source
-  let titleProp = Aeson.object [("title", mkRichTextValue "Database Markdown E2E")]
-      props = Map.fromList [("title", PropertyValue {type_ = Title, value = Just titleProp})]
+  let titleRt = mkPlainRichText "Database Markdown E2E"
+      props = Map.fromList [("title", PV.titleValue (Vector.singleton titleRt))]
       req = mkCreatePage (DataSourceParent {dataSourceId = dsId}) props
   page <- createPage req
   let PageObject {id = pageId} = page
@@ -991,6 +1041,504 @@
   deleted <- deleteView viewId
   let ViewObject {id = deletedViewId} = deleted
   assertEqual "Deleted view ID should match" viewId deletedViewId
+
+-- =====================================================================
+-- Filter and Sort Tests
+-- =====================================================================
+
+testFilterPropertyTitle :: Assertion
+testFilterPropertyTitle = do
+  let f = F.PropertyFilter "Name" (F.TitleCondition (F.TextContains "test"))
+      json = Aeson.toJSON f
+  case json of
+    Aeson.Object o -> do
+      assertEqual "property" (Just (Aeson.String "Name")) (KeyMap.lookup "property" o)
+      case KeyMap.lookup "title" o of
+        Just (Aeson.Object t) ->
+          assertEqual "contains" (Just (Aeson.String "test")) (KeyMap.lookup "contains" t)
+        _ -> assertFailure "Expected title object"
+    _ -> assertFailure "Expected JSON object"
+
+testFilterCompoundAnd :: Assertion
+testFilterCompoundAnd = do
+  let f =
+        F.And
+          [ F.PropertyFilter "Status" (F.SelectCondition (F.SelectEquals "Done")),
+            F.PropertyFilter "Priority" (F.SelectCondition (F.SelectEquals "High"))
+          ]
+      json = Aeson.toJSON f
+  case json of
+    Aeson.Object o ->
+      case KeyMap.lookup "and" o of
+        Just (Aeson.Array arr) -> assertEqual "and array length" 2 (Vector.length arr)
+        _ -> assertFailure "Expected and array"
+    _ -> assertFailure "Expected JSON object"
+
+testFilterTimestamp :: Assertion
+testFilterTimestamp = do
+  let f = F.TimestampFilter F.FilterCreatedTime (F.DateAfter "2024-01-01")
+      json = Aeson.toJSON f
+  case json of
+    Aeson.Object o -> do
+      assertEqual "timestamp" (Just (Aeson.String "created_time")) (KeyMap.lookup "timestamp" o)
+      case KeyMap.lookup "created_time" o of
+        Just (Aeson.Object t) ->
+          assertEqual "after" (Just (Aeson.String "2024-01-01")) (KeyMap.lookup "after" t)
+        _ -> assertFailure "Expected created_time object"
+    _ -> assertFailure "Expected JSON object"
+
+testFilterNumber :: Assertion
+testFilterNumber = do
+  let f = F.PropertyFilter "Score" (F.NumberCondition (F.NumGreaterThan 90))
+      json = Aeson.toJSON f
+  case json of
+    Aeson.Object o -> do
+      assertEqual "property" (Just (Aeson.String "Score")) (KeyMap.lookup "property" o)
+      case KeyMap.lookup "number" o of
+        Just (Aeson.Object n) ->
+          assertEqual "greater_than" (Just (Aeson.Number 90)) (KeyMap.lookup "greater_than" n)
+        _ -> assertFailure "Expected number object"
+    _ -> assertFailure "Expected JSON object"
+
+testFilterDateRelative :: Assertion
+testFilterDateRelative = do
+  let f = F.PropertyFilter "Due" (F.DateCondition F.DateNextWeek)
+      json = Aeson.toJSON f
+  case json of
+    Aeson.Object o ->
+      case KeyMap.lookup "date" o of
+        Just (Aeson.Object d) ->
+          assertEqual "next_week" (Just (Aeson.object [])) (KeyMap.lookup "next_week" d)
+        _ -> assertFailure "Expected date object"
+    _ -> assertFailure "Expected JSON object"
+
+testFilterFormula :: Assertion
+testFilterFormula = do
+  let f = F.PropertyFilter "Computed" (F.FormulaCondition (F.FormulaString (F.TextContains "yes")))
+      json = Aeson.toJSON f
+  case json of
+    Aeson.Object o ->
+      case KeyMap.lookup "formula" o of
+        Just (Aeson.Object fm) ->
+          case KeyMap.lookup "string" fm of
+            Just (Aeson.Object s) ->
+              assertEqual "contains" (Just (Aeson.String "yes")) (KeyMap.lookup "contains" s)
+            _ -> assertFailure "Expected string object inside formula"
+        _ -> assertFailure "Expected formula object"
+    _ -> assertFailure "Expected JSON object"
+
+testSortProperty :: Assertion
+testSortProperty = do
+  let s = F.PropertySort "Name" F.Ascending
+      json = Aeson.toJSON s
+  case json of
+    Aeson.Object o -> do
+      assertEqual "property" (Just (Aeson.String "Name")) (KeyMap.lookup "property" o)
+      assertEqual "direction" (Just (Aeson.String "ascending")) (KeyMap.lookup "direction" o)
+    _ -> assertFailure "Expected JSON object"
+
+testSortTimestamp :: Assertion
+testSortTimestamp = do
+  let s = F.TimestampSort F.FilterCreatedTime F.Descending
+      json = Aeson.toJSON s
+  case json of
+    Aeson.Object o -> do
+      assertEqual "timestamp" (Just (Aeson.String "created_time")) (KeyMap.lookup "timestamp" o)
+      assertEqual "direction" (Just (Aeson.String "descending")) (KeyMap.lookup "direction" o)
+    _ -> assertFailure "Expected JSON object"
+
+testPaginateAll :: Assertion
+testPaginateAll = do
+  -- Mock a 3-page response sequence
+  callCount <- newIORef (0 :: Int)
+  let mockFetch cursor = do
+        modifyIORef' callCount (+ 1)
+        case cursor of
+          Nothing ->
+            pure $
+              ListOf.List
+                { results = Vector.fromList [1 :: Int, 2, 3],
+                  nextCursor = Just "cursor-1",
+                  hasMore = True,
+                  type_ = Nothing,
+                  object = Nothing
+                }
+          Just "cursor-1" ->
+            pure $
+              ListOf.List
+                { results = Vector.fromList [4, 5],
+                  nextCursor = Just "cursor-2",
+                  hasMore = True,
+                  type_ = Nothing,
+                  object = Nothing
+                }
+          _ ->
+            pure $
+              ListOf.List
+                { results = Vector.fromList [6],
+                  nextCursor = Nothing,
+                  hasMore = False,
+                  type_ = Nothing,
+                  object = Nothing
+                }
+  PaginationResult {allResults, totalPages} <- paginateCollect mockFetch
+  assertEqual "all results" (Vector.fromList [1, 2, 3, 4, 5, 6]) allResults
+  assertEqual "total pages" 3 totalPages
+  calls <- readIORef callCount
+  assertEqual "fetch called 3 times" 3 calls
+
+testSerializeNullablePropertyDeletion :: Assertion
+testSerializeNullablePropertyDeletion = do
+  let req =
+        DataSources.UpdateDataSource
+          { title = Nothing,
+            icon = Nothing,
+            properties =
+              Just $
+                Map.fromList
+                  [ ("OldColumn", Nothing),
+                    ("NewColumn", Just (Props.TitleSchema {schemaId = "", schemaName = "NewColumn"}))
+                  ],
+            inTrash = Nothing,
+            parent = Nothing
+          }
+      json = Aeson.toJSON req
+  case json of
+    Aeson.Object o -> do
+      assertBool "should have properties key" (KeyMap.member "properties" o)
+      case KeyMap.lookup "properties" o of
+        Just (Aeson.Object props) -> do
+          -- OldColumn should be null (deletion)
+          assertEqual "OldColumn should be null" (Just Aeson.Null) (KeyMap.lookup "OldColumn" props)
+          -- NewColumn should be a schema object
+          case KeyMap.lookup "NewColumn" props of
+            Just (Aeson.Object _) -> pure ()
+            _ -> assertFailure "Expected NewColumn to be an object"
+        _ -> assertFailure "Expected properties object"
+    _ -> assertFailure "Expected JSON object"
+
+-- =====================================================================
+-- Property Value Tests
+-- =====================================================================
+
+propertyValueTests :: TestTree
+propertyValueTests =
+  testGroup
+    "Property Value"
+    [ testCase "TitleValue FromJSON" testParseTitleValue,
+      testCase "SelectValue FromJSON" testParseSelectValue,
+      testCase "NumberValue FromJSON" testParseNumberValue,
+      testCase "CheckboxValue FromJSON" testParseCheckboxValue,
+      testCase "DateValue FromJSON" testParseDateValue,
+      testCase "RelationValue FromJSON" testParseRelationValue,
+      testCase "StatusValue FromJSON" testParseStatusValue,
+      testCase "MultiSelectValue FromJSON" testParseMultiSelectValue,
+      testCase "UrlValue FromJSON" testParseUrlValue,
+      testCase "FormulaValue FromJSON" testParseFormulaValue,
+      testCase "TitleValue ToJSON" testSerializeTitleValue,
+      testCase "SelectValue ToJSON" testSerializeSelectValue,
+      testCase "NumberValue ToJSON" testSerializeNumberValue,
+      testCase "CheckboxValue ToJSON" testSerializeCheckboxValue,
+      testCase "DateValue ToJSON" testSerializeDateValue,
+      testCase "StatusValue ToJSON" testSerializeStatusValue,
+      testCase "Smart constructor: selectValue" testSmartSelectValue,
+      testCase "Smart constructor: titleValue" testSmartTitleValue
+    ]
+
+testParseTitleValue :: Assertion
+testParseTitleValue = do
+  let json =
+        "{\"id\":\"title\",\"type\":\"title\",\"title\":[{\"type\":\"text\",\"plain_text\":\"Hello\",\"annotations\":{\"bold\":false,\"italic\":false,\"strikethrough\":false,\"underline\":false,\"code\":false,\"color\":\"default\"},\"text\":{\"content\":\"Hello\",\"link\":null}}]}"
+  case Aeson.eitherDecode json of
+    Left err -> assertFailure $ "Failed to parse TitleValue: " <> err
+    Right (PV.TitleValue pid rts) -> do
+      assertEqual "property id" "title" pid
+      assertEqual "rich text count" 1 (Vector.length rts)
+    Right other -> assertFailure $ "Expected TitleValue, got: " <> show other
+
+testParseSelectValue :: Assertion
+testParseSelectValue = do
+  let json =
+        "{\"id\":\"abc\",\"type\":\"select\",\"select\":{\"id\":\"opt-1\",\"name\":\"Done\",\"color\":\"green\"}}"
+  case Aeson.eitherDecode json of
+    Left err -> assertFailure $ "Failed to parse SelectValue: " <> err
+    Right (PV.SelectValue pid (Just (PV.SelectOptionValue _ optName optColor))) -> do
+      assertEqual "property id" "abc" pid
+      assertEqual "option name" "Done" optName
+      assertEqual "option color" (Just "green") optColor
+    Right other -> assertFailure $ "Expected SelectValue, got: " <> show other
+
+testParseNumberValue :: Assertion
+testParseNumberValue = do
+  let json = "{\"id\":\"num\",\"type\":\"number\",\"number\":42}"
+  case Aeson.eitherDecode json of
+    Left err -> assertFailure $ "Failed to parse NumberValue: " <> err
+    Right (PV.NumberValue pid (Just n)) -> do
+      assertEqual "property id" "num" pid
+      assertEqual "number value" (42 :: Scientific) n
+    Right other -> assertFailure $ "Expected NumberValue, got: " <> show other
+
+testParseCheckboxValue :: Assertion
+testParseCheckboxValue = do
+  let json = "{\"id\":\"chk\",\"type\":\"checkbox\",\"checkbox\":true}"
+  case Aeson.eitherDecode json of
+    Left err -> assertFailure $ "Failed to parse CheckboxValue: " <> err
+    Right (PV.CheckboxValue pid v) -> do
+      assertEqual "property id" "chk" pid
+      assertEqual "checkbox value" True v
+    Right other -> assertFailure $ "Expected CheckboxValue, got: " <> show other
+
+testParseDateValue :: Assertion
+testParseDateValue = do
+  let json = "{\"id\":\"dt\",\"type\":\"date\",\"date\":{\"start\":\"2024-01-15\",\"end\":null,\"time_zone\":null}}"
+  case Aeson.eitherDecode json of
+    Left err -> assertFailure $ "Failed to parse DateValue: " <> err
+    Right (PV.DateValue pid (Just d)) -> do
+      assertEqual "property id" "dt" pid
+      assertEqual "start" "2024-01-15" (start (d :: Date))
+    Right other -> assertFailure $ "Expected DateValue, got: " <> show other
+
+testParseRelationValue :: Assertion
+testParseRelationValue = do
+  let json = "{\"id\":\"rel\",\"type\":\"relation\",\"relation\":[{\"id\":\"page-1\"},{\"id\":\"page-2\"}]}"
+  case Aeson.eitherDecode json of
+    Left err -> assertFailure $ "Failed to parse RelationValue: " <> err
+    Right (PV.RelationValue pid refs) -> do
+      assertEqual "property id" "rel" pid
+      assertEqual "relation count" 2 (Vector.length refs)
+    Right other -> assertFailure $ "Expected RelationValue, got: " <> show other
+
+testParseStatusValue :: Assertion
+testParseStatusValue = do
+  let json = "{\"id\":\"st\",\"type\":\"status\",\"status\":{\"id\":\"opt-1\",\"name\":\"In Progress\",\"color\":\"yellow\"}}"
+  case Aeson.eitherDecode json of
+    Left err -> assertFailure $ "Failed to parse StatusValue: " <> err
+    Right (PV.StatusValue pid (Just (PV.SelectOptionValue _ optName _))) -> do
+      assertEqual "property id" "st" pid
+      assertEqual "status name" "In Progress" optName
+    Right other -> assertFailure $ "Expected StatusValue, got: " <> show other
+
+testParseMultiSelectValue :: Assertion
+testParseMultiSelectValue = do
+  let json = "{\"id\":\"ms\",\"type\":\"multi_select\",\"multi_select\":[{\"id\":\"a\",\"name\":\"Tag1\",\"color\":\"red\"},{\"id\":\"b\",\"name\":\"Tag2\",\"color\":\"blue\"}]}"
+  case Aeson.eitherDecode json of
+    Left err -> assertFailure $ "Failed to parse MultiSelectValue: " <> err
+    Right (PV.MultiSelectValue pid opts) -> do
+      assertEqual "property id" "ms" pid
+      assertEqual "option count" 2 (Vector.length opts)
+    Right other -> assertFailure $ "Expected MultiSelectValue, got: " <> show other
+
+testParseUrlValue :: Assertion
+testParseUrlValue = do
+  let json = "{\"id\":\"u\",\"type\":\"url\",\"url\":\"https://example.com\"}"
+  case Aeson.eitherDecode json of
+    Left err -> assertFailure $ "Failed to parse UrlValue: " <> err
+    Right (PV.UrlValue pid (Just u)) -> do
+      assertEqual "property id" "u" pid
+      assertEqual "url" "https://example.com" u
+    Right other -> assertFailure $ "Expected UrlValue, got: " <> show other
+
+testParseFormulaValue :: Assertion
+testParseFormulaValue = do
+  let json = "{\"id\":\"f\",\"type\":\"formula\",\"formula\":{\"type\":\"string\",\"string\":\"hello\"}}"
+  case Aeson.eitherDecode json of
+    Left err -> assertFailure $ "Failed to parse FormulaValue: " <> err
+    Right (PV.FormulaValue pid (PV.FormulaStringResult (Just s))) -> do
+      assertEqual "property id" "f" pid
+      assertEqual "formula string" "hello" s
+    Right other -> assertFailure $ "Expected FormulaValue with string, got: " <> show other
+
+-- Serialization tests
+
+testSerializeTitleValue :: Assertion
+testSerializeTitleValue = do
+  let rt = mkPlainRichText "Hello"
+      pv = PV.titleValue (Vector.singleton rt)
+      json = Aeson.toJSON pv
+  case json of
+    Aeson.Object o -> do
+      assertBool "should have title key" (KeyMap.member "title" o)
+      assertBool "should not have id key" (not $ KeyMap.member "id" o)
+      assertBool "should not have type key" (not $ KeyMap.member "type" o)
+    _ -> assertFailure "Expected JSON object"
+
+testSerializeSelectValue :: Assertion
+testSerializeSelectValue = do
+  let pv = PV.selectValue "Done"
+      json = Aeson.toJSON pv
+  case json of
+    Aeson.Object o -> do
+      assertBool "should have select key" (KeyMap.member "select" o)
+      case KeyMap.lookup "select" o of
+        Just (Aeson.Object sel) ->
+          assertEqual "name" (Just (Aeson.String "Done")) (KeyMap.lookup "name" sel)
+        _ -> assertFailure "Expected select object"
+    _ -> assertFailure "Expected JSON object"
+
+testSerializeNumberValue :: Assertion
+testSerializeNumberValue = do
+  let pv = PV.numberValue 42
+      json = Aeson.toJSON pv
+  case json of
+    Aeson.Object o ->
+      assertEqual "number" (Just (Aeson.Number 42)) (KeyMap.lookup "number" o)
+    _ -> assertFailure "Expected JSON object"
+
+testSerializeCheckboxValue :: Assertion
+testSerializeCheckboxValue = do
+  let pv = PV.checkboxValue True
+      json = Aeson.toJSON pv
+  case json of
+    Aeson.Object o ->
+      assertEqual "checkbox" (Just (Aeson.Bool True)) (KeyMap.lookup "checkbox" o)
+    _ -> assertFailure "Expected JSON object"
+
+testSerializeDateValue :: Assertion
+testSerializeDateValue = do
+  let pv = PV.dateValue "2024-06-01" Nothing
+      json = Aeson.toJSON pv
+  case json of
+    Aeson.Object o -> do
+      assertBool "should have date key" (KeyMap.member "date" o)
+      case KeyMap.lookup "date" o of
+        Just (Aeson.Object d) ->
+          assertEqual "start" (Just (Aeson.String "2024-06-01")) (KeyMap.lookup "start" d)
+        _ -> assertFailure "Expected date object"
+    _ -> assertFailure "Expected JSON object"
+
+testSerializeStatusValue :: Assertion
+testSerializeStatusValue = do
+  let pv = PV.statusValue "In Progress"
+      json = Aeson.toJSON pv
+  case json of
+    Aeson.Object o -> do
+      assertBool "should have status key" (KeyMap.member "status" o)
+      case KeyMap.lookup "status" o of
+        Just (Aeson.Object s) ->
+          assertEqual "name" (Just (Aeson.String "In Progress")) (KeyMap.lookup "name" s)
+        _ -> assertFailure "Expected status object"
+    _ -> assertFailure "Expected JSON object"
+
+testSmartSelectValue :: Assertion
+testSmartSelectValue = do
+  let pv = PV.selectValue "Done"
+  case pv of
+    PV.SelectValue pid (Just (PV.SelectOptionValue _ optName _)) -> do
+      assertEqual "schema id should be empty" "" pid
+      assertEqual "name" "Done" optName
+    _ -> assertFailure "Expected SelectValue"
+
+testSmartTitleValue :: Assertion
+testSmartTitleValue = do
+  let rt = mkPlainRichText "Test"
+      pv = PV.titleValue (Vector.singleton rt)
+  case pv of
+    PV.TitleValue pid rts -> do
+      assertEqual "schema id should be empty" "" pid
+      assertEqual "rich text count" 1 (Vector.length rts)
+    _ -> assertFailure "Expected TitleValue"
+
+-- =====================================================================
+-- Property Schema Tests
+-- =====================================================================
+
+testPropertySchemaSelectRoundTrip :: Assertion
+testPropertySchemaSelectRoundTrip = do
+  let opts =
+        Vector.fromList
+          [ Props.SelectOption {id = Just "opt-1", name = "Done", color = Just Props.Green},
+            Props.SelectOption {id = Just "opt-2", name = "Todo", color = Just Props.Red}
+          ]
+      schema = Props.SelectSchema {schemaId = "abc", schemaName = "Status", selectOptions = opts}
+      json = Aeson.toJSON schema
+  case Aeson.fromJSON json of
+    Aeson.Success decoded -> assertEqual "round-trip" schema decoded
+    Aeson.Error err -> assertFailure $ "Failed to decode: " <> err
+
+testPropertySchemaNumberRoundTrip :: Assertion
+testPropertySchemaNumberRoundTrip = do
+  let schema = Props.NumberSchema {schemaId = "n1", schemaName = "Price", numberFormat = Props.Dollar}
+      json = Aeson.toJSON schema
+  case Aeson.fromJSON json of
+    Aeson.Success decoded -> assertEqual "round-trip" schema decoded
+    Aeson.Error err -> assertFailure $ "Failed to decode: " <> err
+
+testPropertySchemaFormulaRoundTrip :: Assertion
+testPropertySchemaFormulaRoundTrip = do
+  let schema = Props.FormulaSchema {schemaId = "f1", schemaName = "Total", formulaExpression = "prop(\"Price\") * 2"}
+      json = Aeson.toJSON schema
+  case Aeson.fromJSON json of
+    Aeson.Success decoded -> assertEqual "round-trip" schema decoded
+    Aeson.Error err -> assertFailure $ "Failed to decode: " <> err
+
+testPropertySchemaRelationRoundTrip :: Assertion
+testPropertySchemaRelationRoundTrip = do
+  let relType = Props.DualProperty {syncedPropertyId = "sp1", syncedPropertyName = "Related"}
+      schema = Props.RelationSchema {schemaId = "r1", schemaName = "Tasks", relationDataSourceId = UUID "ds-123", relationType = relType}
+      json = Aeson.toJSON schema
+  case Aeson.fromJSON json of
+    Aeson.Success decoded -> assertEqual "round-trip" schema decoded
+    Aeson.Error err -> assertFailure $ "Failed to decode: " <> err
+
+testPropertySchemaStatusRoundTrip :: Assertion
+testPropertySchemaStatusRoundTrip = do
+  let opts =
+        Vector.fromList
+          [ Props.SelectOption {id = Just "s1", name = "Not Started", color = Just Props.Gray},
+            Props.SelectOption {id = Just "s2", name = "Done", color = Just Props.Green}
+          ]
+      grps =
+        Vector.fromList
+          [ Props.StatusGroup {id = Just "g1", name = "To-do", color = Just Props.Gray, optionIds = Vector.fromList ["s1"]},
+            Props.StatusGroup {id = Just "g2", name = "Complete", color = Just Props.Green, optionIds = Vector.fromList ["s2"]}
+          ]
+      schema = Props.StatusSchema {schemaId = "st1", schemaName = "Status", statusOptions = opts, statusGroups = grps}
+      json = Aeson.toJSON schema
+  case Aeson.fromJSON json of
+    Aeson.Success decoded -> assertEqual "round-trip" schema decoded
+    Aeson.Error err -> assertFailure $ "Failed to decode: " <> err
+
+testNumberFormatRoundTrip :: Assertion
+testNumberFormatRoundTrip = do
+  let formats =
+        [ Props.NumberPlain,
+          Props.Dollar,
+          Props.Euro,
+          Props.Percent,
+          Props.NumberWithCommas,
+          Props.Yen,
+          Props.PhilippinePeso,
+          Props.SingaporeDollar
+        ]
+  mapM_
+    ( \fmt -> do
+        let json = Aeson.toJSON fmt
+        case Aeson.fromJSON json of
+          Aeson.Success decoded -> assertEqual ("round-trip for " <> show fmt) fmt decoded
+          Aeson.Error err -> assertFailure $ "Failed to decode " <> show fmt <> ": " <> err
+    )
+    formats
+
+testRollupFunctionRoundTrip :: Assertion
+testRollupFunctionRoundTrip = do
+  let fns =
+        [ Props.CountAll,
+          Props.Sum,
+          Props.Average,
+          Props.ShowOriginal,
+          Props.Checked,
+          Props.DateRange,
+          Props.ShowUnique,
+          Props.NotEmpty
+        ]
+  mapM_
+    ( \fn -> do
+        let json = Aeson.toJSON fn
+        case Aeson.fromJSON json of
+          Aeson.Success decoded -> assertEqual ("round-trip for " <> show fn) fn decoded
+          Aeson.Error err -> assertFailure $ "Failed to decode " <> show fn <> ": " <> err
+    )
+    fns
 
 -- =====================================================================
 -- Helpers
