diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,16 @@
+# Changelog for notion-client
+
+## 0.1.0.0 (2026-02-28)
+
+* Initial release
+* Support for core Notion API endpoints:
+  * Databases
+  * Data Sources
+  * Pages
+  * Blocks
+  * Users
+  * Search
+  * Comments
+  * Webhooks (event types and signature verification)
+* Type-safe client with Servant-based implementation
+* Targets Notion API version 2025-09-03
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Nadeem Bitar
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,114 @@
+# Notion API Client for Haskell
+
+A type-safe Haskell client for the [Notion API](https://developers.notion.com/reference/intro) (version `2025-09-03`).
+
+## Features
+
+- Type-safe API bindings using Servant
+- Comprehensive coverage of Notion API endpoints
+- Support for all Notion object types: Pages, Databases, Data Sources, Blocks, Users, etc.
+- Simple client interface with sensible defaults
+
+## Installation
+
+Add to your `package.yaml` or `.cabal` file:
+
+```yaml
+dependencies:
+  - notion-client
+```
+
+## Usage
+
+Here's a simple example of retrieving a Notion page:
+
+```haskell
+module Main where
+
+import Notion.V1
+import Notion.V1.Pages
+import Data.Text qualified as Text
+import System.Environment qualified as Environment
+
+main :: IO ()
+main = do
+    token <- Environment.getEnv "NOTION_TOKEN"
+
+    clientEnv <- getClientEnv "https://api.notion.com/v1"
+
+    let Methods{ retrievePage } = makeMethods clientEnv (Text.pack token)
+
+    page <- retrievePage "page-id-here"
+
+    print page
+```
+
+### Creating a page
+
+```haskell
+import Notion.V1
+import Notion.V1.Common (Parent(..))
+import Notion.V1.Pages
+import Data.Map qualified as Map
+import Data.Aeson (toJSON)
+
+createNewPage :: Methods -> IO PageObject
+createNewPage Methods{createPage} = do
+    let pageProperties = Map.fromList
+            [ ("title", PropertyValue
+                { type_ = Title
+                , value = Just $ toJSON [-- rich text objects --]
+                })
+            ]
+
+        newPage = mkCreatePage
+            (DataSourceParent { dataSourceId = "data-source-id-here" })
+            pageProperties
+
+    createPage newPage
+```
+
+## API Coverage
+
+- Databases: Create, retrieve, and update databases
+- Data Sources: Create, retrieve, update, and query data sources
+- Pages: Create, retrieve, and update pages
+- Blocks: Retrieve, update, append children, and delete blocks
+- Users: List, retrieve users and bot users
+- Search: Search for pages and data sources
+- Comments: Create and list comments
+- Webhooks: Event types and signature verification
+
+## Running the Example
+
+The repository includes a comprehensive example in the `notion-client-example` directory that demonstrates how to use most API endpoints.
+
+To run the example:
+
+```bash
+# Set required environment variables
+export NOTION_TOKEN="your-integration-token"
+
+# Optional: Set these if you want to test specific database/page endpoints
+export NOTION_TEST_DATABASE_ID="your-database-id"
+export NOTION_TEST_PAGE_ID="your-page-id"
+
+# Run the example
+cabal run notion-client-example
+```
+
+### Obtaining API Credentials
+
+1. Create a Notion integration at [https://www.notion.so/my-integrations](https://www.notion.so/my-integrations)
+2. Get your integration token from the integration settings
+3. Share any Notion pages or databases you want to access with your integration
+   - Open the page/database in Notion
+   - Click "Share" in the top right
+   - Enter your integration name and click "Invite"
+4. Get the page/database IDs from their URLs:
+   - Page URL: `https://www.notion.so/Your-Page-Title-83715d7c1111424aaa11d7fc1111bd2a`
+   - Page ID: `83715d7c1111424aaa11d7fc1111bd2a` (the last part of the URL)
+
+## License
+
+BSD-3-Clause
diff --git a/notion-client-example/Blocks.hs b/notion-client-example/Blocks.hs
new file mode 100644
--- /dev/null
+++ b/notion-client-example/Blocks.hs
@@ -0,0 +1,67 @@
+-- |
+-- Helper functions for creating Notion block JSON objects.
+--
+-- These functions create JSON objects that match the Notion API's block format.
+module Blocks
+  ( createParagraphBlock,
+    createHeadingBlock,
+    createBulletedListItemBlock,
+  )
+where
+
+import Data.Aeson qualified as Aeson
+import Data.String (fromString)
+import Data.Text (Text)
+import Data.Text qualified as Text
+import Data.Vector qualified as Vector
+
+-- | Create a paragraph block with text content
+-- @param content The text content to include in the paragraph
+-- @return A JSON representation of a paragraph block
+createParagraphBlock :: Text -> Aeson.Value
+createParagraphBlock content =
+  let -- Structure: {"text": {"content": "Your text here"}}
+      textObj = Aeson.object [("content", Aeson.String content)]
+      textItem = Aeson.object [("text", textObj)]
+      -- Rich text is always an array of text items
+      richText = Aeson.Array (Vector.singleton textItem)
+      -- The paragraph property contains rich_text array
+      paragraphContent = Aeson.object [("rich_text", richText)]
+   in Aeson.object
+        [ ("type", Aeson.String "paragraph"), -- Block type
+          ("paragraph", paragraphContent) -- Block content
+        ]
+
+-- | Create a heading block with text content
+-- @param content The text content for the heading
+-- @param level The heading level (1-3)
+-- @return A JSON representation of a heading block
+createHeadingBlock :: Text -> Int -> Aeson.Value
+createHeadingBlock content level =
+  let -- Create the same rich text structure as paragraph
+      textObj = Aeson.object [("content", Aeson.String content)]
+      textItem = Aeson.object [("text", textObj)]
+      richText = Aeson.Array (Vector.singleton textItem)
+      -- Headings use heading_1, heading_2, or heading_3 as type
+      headingType = "heading_" <> Text.pack (show level)
+      headingContent = Aeson.object [("rich_text", richText)]
+   in Aeson.object
+        [ ("type", Aeson.String headingType), -- Block type
+          (fromString (Text.unpack headingType), headingContent) -- Block content
+        ]
+
+-- | Create a bulleted list item block with text content
+-- @param content The text content for the list item
+-- @return A JSON representation of a bulleted list item block
+createBulletedListItemBlock :: Text -> Aeson.Value
+createBulletedListItemBlock content =
+  let -- Create the same rich text structure as other blocks
+      textObj = Aeson.object [("content", Aeson.String content)]
+      textItem = Aeson.object [("text", textObj)]
+      richText = Aeson.Array (Vector.singleton textItem)
+      -- List items are structured the same way as paragraphs
+      listContent = Aeson.object [("rich_text", richText)]
+   in Aeson.object
+        [ ("type", Aeson.String "bulleted_list_item"), -- Block type
+          ("bulleted_list_item", listContent) -- Block content
+        ]
diff --git a/notion-client-example/Console.hs b/notion-client-example/Console.hs
new file mode 100644
--- /dev/null
+++ b/notion-client-example/Console.hs
@@ -0,0 +1,39 @@
+-- |
+-- Console output helpers for the example application.
+module Console
+  ( printHeader,
+    logError,
+    printSuccess,
+    runTest,
+  )
+where
+
+import Data.Text (Text)
+import Data.Text qualified as Text
+import System.Exit (exitFailure)
+import System.IO (hPutStrLn, stderr)
+
+-- | Print a section header
+printHeader :: Text -> IO ()
+printHeader title = do
+  putStrLn ""
+  putStrLn $ "=== " <> Text.unpack title <> " ==="
+  putStrLn ""
+
+-- | Log an error and exit
+logError :: Text -> IO a
+logError msg = do
+  hPutStrLn stderr $ "ERROR: " <> Text.unpack msg
+  exitFailure
+
+-- | Print a success message
+printSuccess :: Text -> IO ()
+printSuccess msg = putStrLn $ "✓ " <> Text.unpack msg
+
+-- | Run a test with label
+runTest :: Text -> IO a -> IO a
+runTest label action = do
+  putStr $ Text.unpack label <> "... "
+  result <- action
+  printSuccess (Text.pack "Done")
+  pure result
diff --git a/notion-client-example/DatabaseDemo.hs b/notion-client-example/DatabaseDemo.hs
new file mode 100644
--- /dev/null
+++ b/notion-client-example/DatabaseDemo.hs
@@ -0,0 +1,344 @@
+-- |
+-- Database API demonstration.
+module DatabaseDemo
+  ( runDatabaseDemo,
+  )
+where
+
+import Blocks (createBulletedListItemBlock, createHeadingBlock, createParagraphBlock)
+import Console (printHeader, runTest)
+import Data.Aeson qualified as Aeson
+import Data.Map (fromList)
+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.DataSources qualified as DataSources
+import Notion.V1.Databases (DataSource (..), DatabaseObject (..))
+import Notion.V1.ListOf (ListOf (..))
+import Notion.V1.Pages (CreatePage (..), PageObject (..), PropertyValue (..), PropertyValueType (Select, Title))
+import Notion.V1.RichText (RichText (..), RichTextContent (..), TextContent (..), defaultAnnotations)
+import Prelude hiding (id)
+
+-- | Run the Database API demonstration
+runDatabaseDemo :: Methods -> String -> IO ()
+runDatabaseDemo methods databaseIdStr = do
+  let databaseId = fromString databaseIdStr
+
+  printHeader (Text.pack "Database API")
+
+  -- Retrieve database and display new fields
+  database <-
+    runTest (Text.pack "Retrieving database") $
+      retrieveDatabase methods databaseId
+  putStrLn $ "Database retrieved, ID: " <> databaseIdStr
+
+  -- Display database fields
+  let DatabaseObject {isInline, inTrash, publicUrl, dataSources, isLocked} = database
+  putStrLn $ "  isInline: " <> show isInline
+  putStrLn $ "  inTrash: " <> show inTrash
+  putStrLn $ "  isLocked: " <> show isLocked
+  putStrLn $ "  publicUrl: " <> show publicUrl
+  putStrLn $ "  dataSources: " <> show dataSources
+
+  -- Retrieve the first data source to inspect its schema
+  -- Note: In API version 2025-09-03, querying goes through data sources, not databases
+  printHeader (Text.pack "Data Source API")
+
+  let DataSource {id = dsId, name = dsName} = Vector.head dataSources
+  putStrLn $ "First data source: " <> Text.unpack dsName <> " (" <> show dsId <> ")"
+
+  dataSource <-
+    runTest (Text.pack "Retrieving data source") $
+      retrieveDataSource methods dsId
+  let DataSources.DataSourceObject {properties = dsProperties, parent = dsParent} = dataSource
+  putStrLn $ "  parent: " <> show dsParent
+  putStrLn $ "  properties: " <> show dsProperties
+
+  -- Query the data source directly (preferred over queryDatabase in 2025-09-03)
+  let dsQueryParams =
+        DataSources.QueryDataSource
+          { filter = Nothing,
+            sorts = Nothing,
+            startCursor = Nothing,
+            pageSize = Just 5,
+            archived = Nothing,
+            inTrash = Nothing
+          }
+  dsResults <-
+    runTest (Text.pack "Querying data source") $
+      queryDataSource methods dsId dsQueryParams
+  let List {results = dsQueryResults} = dsResults
+  putStrLn $ "Data source query returned " <> show (Vector.length dsQueryResults) <> " results"
+
+  -- Create a new data source within the database
+  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 [])
+                ]
+            )
+          ]
+
+      createDsRequest =
+        DataSources.CreateDataSource
+          { parent = DatabaseParent {databaseId = databaseId},
+            properties = newDsProperties,
+            title = Nothing,
+            icon = Nothing
+          }
+
+  newDataSource <-
+    runTest (Text.pack "Creating new data source in database") $
+      createDataSource methods createDsRequest
+
+  let DataSources.DataSourceObject {id = newDsId, properties = newDsProps} = newDataSource
+  putStrLn $ "New data source created with ID: " <> show newDsId
+  putStrLn $ "  properties: " <> show newDsProps
+
+  -- Update data source schema: add properties
+  printHeader (Text.pack "Updating Data Source Schema")
+
+  let -- Define Status and Priority select properties with options
+      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")]
+                              ]
+                        )
+                      ]
+                  )
+                ]
+            )
+          ]
+
+      updateDsRequest =
+        DataSources.UpdateDataSource
+          { title = Nothing,
+            icon = Nothing,
+            properties = Just combinedProperties,
+            inTrash = Nothing,
+            archived = Nothing,
+            parent = Nothing
+          }
+
+  -- Update the data source with new properties
+  _updatedDataSource <-
+    runTest (Text.pack "Adding Status and Priority properties via data source") $
+      updateDataSource methods dsId updateDsRequest
+
+  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")]
+            )
+          ]
+
+      -- 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
+                }
+            )
+          ]
+
+      -- Step 4: Create initial blocks for the page (optional)
+      -- Pages can be created with content already in them
+      initialBlocks =
+        Vector.fromList
+          [ createHeadingBlock "Initial Content" 1,
+            createParagraphBlock "This page was created with initial content via the Notion API."
+          ]
+
+      -- Step 5: Assemble the CreatePage request
+      -- In API version 2025-09-03, pages are created under a data source
+      createPageRequest =
+        CreatePage
+          { parent = DataSourceParent {dataSourceId = dsId}, -- Specify parent data source
+            properties = pageProperties, -- Required page properties
+            children = Just initialBlocks, -- Optional initial content
+            icon = Just (EmojiIcon "📝"), -- Optional page icon
+            cover = Nothing -- Optional page cover
+          }
+
+  -- Add page to database
+  newPage <-
+    runTest (Text.pack "Creating new page in database") $
+      createPage methods createPageRequest
+
+  let PageObject {id = newPageId, url = newPageUrl} = newPage
+  putStrLn $ "New page created. Access at: " <> Text.unpack newPageUrl
+
+  -- Retrieve the new page
+  retrievedPage <-
+    runTest (Text.pack "Retrieving newly created page") $
+      retrievePage methods newPageId
+
+  let PageObject {url = retrievedPageUrl} = retrievedPage
+  putStrLn $ "Retrieved page URL: " <> Text.unpack retrievedPageUrl
+
+  -- Add blocks to the newly created page
+  let additionalBlocks =
+        Vector.fromList
+          [ createHeadingBlock "Example Content" 1,
+            createParagraphBlock "This is a paragraph with some example content created via the Notion API.",
+            createHeadingBlock "Features" 2,
+            createBulletedListItemBlock "Create pages in databases",
+            createBulletedListItemBlock "Add rich content to pages",
+            createBulletedListItemBlock "Query and retrieve data"
+          ]
+      appendRequest = Blocks.AppendBlockChildren {children = additionalBlocks}
+
+  -- Add blocks to the page
+  _updatedPage <-
+    runTest (Text.pack "Adding blocks to page") $
+      appendBlockChildren methods newPageId appendRequest
+
+  -- Fetch the blocks to verify
+  pageBlocks <-
+    runTest (Text.pack "Retrieving page blocks") $
+      listBlockChildren methods newPageId Nothing Nothing
+
+  let List {results = blockResults} = pageBlocks
+  putStrLn $ "Page now contains " <> show (Vector.length blockResults) <> " blocks"
+
+  -- Add a comment to the newly created page
+  printHeader (Text.pack "Adding Comment to Page")
+
+  let -- Create rich text content for the comment using typed RichText
+      commentRichText =
+        Vector.singleton
+          RichText
+            { plainText = "This is an automated comment added via the Notion API! 🎉",
+              href = Nothing,
+              annotations = defaultAnnotations,
+              type_ = "text",
+              content = TextContentWrapper (TextContent {content = "This is an automated comment added via the Notion API! 🎉", link = Nothing})
+            }
+
+      -- Create the parent reference using the typed Parent constructor
+      commentParent = PageParent {pageId = newPageId}
+
+      -- Create the comment request
+      createCommentRequest =
+        CreateComment
+          { parent = commentParent,
+            richText = commentRichText,
+            discussionId = Nothing -- Creates a new discussion thread
+          }
+
+  -- Create the comment
+  newComment <-
+    runTest (Text.pack "Creating comment on page") $
+      createComment methods createCommentRequest
+
+  let CommentObject {id = commentId, discussionId = discId} = newComment
+  putStrLn $ "Comment created with ID: " <> show commentId
+  putStrLn $ "Discussion ID: " <> show discId
+
+  -- Add a reply to the same discussion thread
+  let -- Create reply rich text using typed RichText
+      replyRichText =
+        Vector.singleton
+          RichText
+            { plainText = "This is a reply in the same discussion thread.",
+              href = Nothing,
+              annotations = defaultAnnotations,
+              type_ = "text",
+              content = TextContentWrapper (TextContent {content = "This is a reply in the same discussion thread.", link = Nothing})
+            }
+
+      -- Reply to existing discussion by providing discussion_id
+      replyRequest =
+        CreateComment
+          { parent = commentParent,
+            richText = replyRichText,
+            discussionId = Just discId -- Reply to the same discussion
+          }
+
+  _replyComment <-
+    runTest (Text.pack "Adding reply to discussion") $
+      createComment methods replyRequest
+
+  putStrLn "Reply added to discussion"
+
+  -- List all comments on the page
+  allComments <-
+    runTest (Text.pack "Listing all comments on page") $
+      listComments methods (Just newPageId) Nothing (Just 10)
+
+  let List {results = commentResults} = allComments
+  putStrLn $ "Page now has " <> show (Vector.length commentResults) <> " comments"
diff --git a/notion-client-example/Main.hs b/notion-client-example/Main.hs
new file mode 100644
--- /dev/null
+++ b/notion-client-example/Main.hs
@@ -0,0 +1,131 @@
+-- |
+-- Notion API Client Example (API version 2025-09-03)
+--
+-- This example demonstrates using the Notion API client to interact with Notion:
+-- - Retrieving users and user information
+-- - Retrieving databases and their data sources
+-- - Updating data source schema (adding properties)
+-- - Creating pages under data sources with properties and content
+-- - Adding different types of blocks to pages
+-- - Creating comments on pages and on specific blocks
+-- - Listing and inspecting comments (with attachments and displayName)
+-- - Querying and searching content
+--
+-- To run this example:
+--
+-- 1. Get a Notion API token from https://www.notion.so/my-integrations
+-- 2. Set the NOTION_TOKEN environment variable with your token
+-- 3. (Optional) Set NOTION_TEST_DATABASE_ID with a database ID to test database operations
+-- 4. (Optional) Set NOTION_TEST_PAGE_ID with a page ID to test page operations
+-- 5. Run with: cabal run notion-client-example
+--
+-- Note: Your integration must have access to the specified database and page.
+module Main where
+
+import Console (logError, printHeader, runTest)
+import Control.Monad (when)
+import Data.Maybe (isNothing)
+import Data.Text qualified as Text
+import Data.Vector qualified as Vector
+import DatabaseDemo (runDatabaseDemo)
+import Notion.V1 (Methods (..), getClientEnv, makeMethods)
+import Notion.V1.Search (SearchRequest (..), SearchResult (..), SearchSort (..), SearchSortDirection (..), dataSourceFilter, pageFilter, parseSearchResults)
+import PageDemo (runPageDemo)
+import System.Environment qualified as Environment
+import UserDemo (runUserDemo)
+
+main :: IO ()
+main = do
+  putStrLn "Notion API Client Example"
+  putStrLn "========================="
+
+  -- Get environment variables with error handling
+  token <- do
+    mToken <- Environment.lookupEnv "NOTION_TOKEN"
+    case mToken of
+      Just t -> pure (Text.pack t)
+      Nothing -> logError (Text.pack "NOTION_TOKEN environment variable is required")
+
+  databaseIdEnv <- Environment.lookupEnv "NOTION_TEST_DATABASE_ID"
+  pageIdEnv <- Environment.lookupEnv "NOTION_TEST_PAGE_ID"
+
+  when (isNothing databaseIdEnv && isNothing pageIdEnv) $
+    putStrLn "WARNING: Neither NOTION_TEST_DATABASE_ID nor NOTION_TEST_PAGE_ID are set.\n         Only basic user API functionality will be demonstrated."
+
+  printHeader (Text.pack "Client Initialization")
+  clientEnv <- getClientEnv (Text.pack "https://api.notion.com/v1")
+  putStrLn "Client initialized"
+
+  let methods = makeMethods clientEnv token
+
+  -- User API demo
+  runUserDemo methods
+
+  -- Optional Database tests
+  case databaseIdEnv of
+    Just databaseIdStr -> runDatabaseDemo methods databaseIdStr
+    Nothing ->
+      putStrLn "Skipping database tests (set NOTION_TEST_DATABASE_ID to enable)"
+
+  -- Optional Page tests
+  case pageIdEnv of
+    Just pageIdStr -> runPageDemo methods pageIdStr
+    Nothing ->
+      putStrLn "Skipping page tests (set NOTION_TEST_PAGE_ID to enable)"
+
+  -- Search API
+  printHeader (Text.pack "Search API")
+
+  -- General search
+  let searchParams =
+        SearchRequest
+          { query = Nothing,
+            sort = Just (SearchSort {direction = Descending, timestamp = Text.pack "last_edited_time"}),
+            filter = Nothing,
+            startCursor = Nothing,
+            pageSize = Just 5
+          }
+  rawResults <-
+    runTest (Text.pack "Searching (all objects, sorted by last_edited_time)") $
+      search methods searchParams
+
+  let typedResults = parseSearchResults rawResults
+  putStrLn $ "  Found " <> show (Vector.length typedResults) <> " typed results"
+  Vector.forM_ typedResults $ \result ->
+    case result of
+      PageResult _ -> putStrLn "  - page"
+      DataSourceResult _ -> putStrLn "  - data_source"
+
+  -- Search filtered to pages only
+  let pageSearchParams =
+        SearchRequest
+          { query = Nothing,
+            sort = Nothing,
+            filter = Just pageFilter,
+            startCursor = Nothing,
+            pageSize = Just 3
+          }
+  pageResults <-
+    runTest (Text.pack "Searching (pages only)") $
+      search methods pageSearchParams
+  let typedPageResults = parseSearchResults pageResults
+  putStrLn $ "  Found " <> show (Vector.length typedPageResults) <> " pages"
+
+  -- Search filtered to data sources only
+  let dsSearchParams =
+        SearchRequest
+          { query = Nothing,
+            sort = Nothing,
+            filter = Just dataSourceFilter,
+            startCursor = Nothing,
+            pageSize = Just 3
+          }
+  dsResults <-
+    runTest (Text.pack "Searching (data sources only)") $
+      search methods dsSearchParams
+  let typedDsResults = parseSearchResults dsResults
+  putStrLn $ "  Found " <> show (Vector.length typedDsResults) <> " data sources"
+
+  -- All done
+  printHeader (Text.pack "Test complete")
+  putStrLn "All tests completed successfully!"
diff --git a/notion-client-example/PageDemo.hs b/notion-client-example/PageDemo.hs
new file mode 100644
--- /dev/null
+++ b/notion-client-example/PageDemo.hs
@@ -0,0 +1,189 @@
+-- |
+-- Page API demonstration.
+module PageDemo
+  ( runPageDemo,
+  )
+where
+
+import Console (printHeader, runTest)
+import Control.Monad (when)
+import Data.Aeson qualified as Aeson
+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 (Parent (..))
+import Notion.V1.ListOf (ListOf (..))
+import Notion.V1.RichText (RichText (..), RichTextContent (..), TextContent (..), defaultAnnotations)
+import Prelude hiding (id)
+
+-- | Run the Page API demonstration
+runPageDemo :: Methods -> String -> IO ()
+runPageDemo methods pageIdStr = do
+  let pageId = fromString pageIdStr
+
+  printHeader (Text.pack "Page API")
+
+  -- Retrieve page
+  _page <-
+    runTest (Text.pack "Retrieving page") $
+      retrievePage methods pageId
+  putStrLn $ "Page retrieved, ID: " <> pageIdStr
+
+  -- List blocks
+  blocks <-
+    runTest (Text.pack "Listing blocks") $
+      listBlockChildren methods pageId Nothing Nothing
+  let List {results = blockResults} = blocks
+  putStrLn $ "Block count: " <> show (Vector.length blockResults)
+
+  -- If we have blocks, retrieve the first one and add a comment to it
+  when (not $ Vector.null blockResults) $ do
+    let firstBlock = Vector.head blockResults
+        firstBlockId = Blocks.id firstBlock
+    block <-
+      runTest (Text.pack "Retrieving block") $
+        retrieveBlock methods firstBlockId
+    putStrLn $ "Block retrieved, type: " <> Text.unpack (Blocks.type_ block)
+
+    -- Add a comment to the specific block (not the page)
+    -- This demonstrates commenting on inline content
+    printHeader (Text.pack "Adding Comment to Block")
+
+    let -- Create rich text content for the block comment using typed RichText
+        blockCommentRichText =
+          Vector.singleton
+            RichText
+              { plainText = "This comment is attached to a specific block, not the page!",
+                href = Nothing,
+                annotations = defaultAnnotations,
+                type_ = "text",
+                content = TextContentWrapper (TextContent {content = "This comment is attached to a specific block, not the page!", link = Nothing})
+              }
+
+        -- Create the parent reference using the typed Parent constructor
+        -- This is different from page comments which use PageParent
+        blockCommentParent = BlockParent {blockId = firstBlockId}
+
+        -- Create the comment request for the block
+        createBlockCommentRequest =
+          CreateComment
+            { parent = blockCommentParent,
+              richText = blockCommentRichText,
+              discussionId = Nothing -- Creates a new discussion thread on the block
+            }
+
+    -- Create the comment on the block
+    blockComment <-
+      runTest (Text.pack "Creating comment on block") $
+        createComment methods createBlockCommentRequest
+
+    let CommentObject {id = blockCommentId, discussionId = blockDiscId} = blockComment
+    putStrLn $ "Block comment created with ID: " <> show blockCommentId
+    putStrLn $ "Block discussion ID: " <> show blockDiscId
+
+    -- List comments on the block
+    blockComments <-
+      runTest (Text.pack "Listing comments on block") $
+        listComments methods (Just firstBlockId) Nothing (Just 10)
+
+    let List {results = blockCommentResults} = blockComments
+    putStrLn $ "Block has " <> show (Vector.length blockCommentResults) <> " comments"
+
+  -- Add new blocks to the existing page
+  let codeBlock =
+        Aeson.object
+          [ ("type", Aeson.String "code"),
+            ( "code",
+              Aeson.object
+                [ ( "rich_text",
+                    Aeson.Array
+                      ( Vector.singleton $
+                          Aeson.object
+                            [ ("text", Aeson.object [("content", Aeson.String "const example = () => {\n  console.log('Hello from Notion API');\n};")])
+                            ]
+                      )
+                  ),
+                  ("language", Aeson.String "javascript")
+                ]
+            )
+          ]
+      quoteBlock =
+        Aeson.object
+          [ ("type", Aeson.String "quote"),
+            ( "quote",
+              Aeson.object
+                [ ( "rich_text",
+                    Aeson.Array
+                      ( Vector.singleton $
+                          Aeson.object
+                            [ ("text", Aeson.object [("content", Aeson.String "This is a quote block added via the API")])
+                            ]
+                      )
+                  )
+                ]
+            )
+          ]
+      calloutBlock =
+        Aeson.object
+          [ ("type", Aeson.String "callout"),
+            ( "callout",
+              Aeson.object
+                [ ( "rich_text",
+                    Aeson.Array
+                      ( Vector.singleton $
+                          Aeson.object
+                            [ ("text", Aeson.object [("content", Aeson.String "This is a callout block with an emoji")])
+                            ]
+                      )
+                  ),
+                  ("icon", Aeson.object [("emoji", Aeson.String "🔥")])
+                ]
+            )
+          ]
+      specializedBlocks = Vector.fromList [codeBlock, quoteBlock, calloutBlock]
+      appendRequest = Blocks.AppendBlockChildren {children = specializedBlocks}
+
+  -- Append blocks to the existing page
+  _updatedPage <-
+    runTest (Text.pack "Adding specialized blocks to page") $
+      appendBlockChildren methods pageId appendRequest
+
+  -- Refresh the blocks to see all blocks now
+  allBlocks <-
+    runTest (Text.pack "Retrieving all page blocks") $
+      listBlockChildren methods pageId Nothing Nothing
+
+  let List {results = allBlockResults} = allBlocks
+  putStrLn $ "Page now contains " <> show (Vector.length allBlockResults) <> " blocks"
+
+  -- Comments API demonstration (using the page)
+  -- Note: Pages are blocks in Notion, so we use the page ID as block_id
+  printHeader (Text.pack "Comments API")
+
+  -- List comments on the page using block_id (pages are blocks in Notion)
+  comments <-
+    runTest (Text.pack "Listing comments on page") $
+      listComments methods (Just pageId) Nothing (Just 10)
+
+  let List {results = commentResults} = comments
+  putStrLn $ "Found " <> show (Vector.length commentResults) <> " comments on page"
+
+  -- Display comment details if any exist
+  when (not $ Vector.null commentResults) $ do
+    let firstComment = Vector.head commentResults
+        CommentObject
+          { id = commentId,
+            discussionId = discId,
+            createdBy = createdBy,
+            attachments = commentAttachments,
+            displayName = displayName
+          } = firstComment
+    putStrLn "First comment details:"
+    putStrLn $ "  id: " <> show commentId
+    putStrLn $ "  discussionId: " <> show discId
+    putStrLn $ "  createdBy: " <> show createdBy
+    putStrLn $ "  attachments: " <> show commentAttachments
+    putStrLn $ "  displayName: " <> show displayName
diff --git a/notion-client-example/UserDemo.hs b/notion-client-example/UserDemo.hs
new file mode 100644
--- /dev/null
+++ b/notion-client-example/UserDemo.hs
@@ -0,0 +1,29 @@
+-- |
+-- User API demonstration.
+module UserDemo
+  ( runUserDemo,
+  )
+where
+
+import Console (printHeader, runTest)
+import Data.Text qualified as Text
+import Data.Vector qualified as Vector
+import Notion.V1 (Methods (..))
+import Notion.V1.ListOf (ListOf (..))
+
+-- | Run the User API demonstration
+runUserDemo :: Methods -> IO ()
+runUserDemo methods = do
+  printHeader (Text.pack "User API")
+
+  user <-
+    runTest (Text.pack "Retrieving current user information") $
+      retrieveMyUser methods
+  putStrLn $ "User info: " <> show user
+
+  -- List all users
+  users <-
+    runTest (Text.pack "Listing users") $
+      listUsers methods Nothing Nothing
+  let List {results = userResults} = users
+  putStrLn $ "User count: " <> show (Vector.length userResults)
diff --git a/notion-client.cabal b/notion-client.cabal
new file mode 100644
--- /dev/null
+++ b/notion-client.cabal
@@ -0,0 +1,132 @@
+cabal-version:      3.4
+name:               notion-client
+version:            0.1.0.0
+synopsis:           Type-safe Haskell client for the Notion API
+description:
+  This package provides comprehensive and type-safe bindings
+  to the Notion API, providing both a Servant interface and
+  non-Servant interface for convenience.
+  .
+  Read the @README@ below for a fully worked usage example.
+  .
+  Otherwise, browse the "Notion.V1" module, which is the
+  intended package entrypoint.
+
+license:            MIT
+license-file:       LICENSE
+category:           Web
+author:             Nadeem Bitar
+maintainer:         nadeem@gmail.com
+homepage:           https://github.com/shinzui/notion-client
+bug-reports:        https://github.com/shinzui/notion-client/issues
+build-type:         Simple
+tested-with:        GHC ==9.12.2
+extra-doc-files:
+  CHANGELOG.md
+  README.md
+
+extra-source-files: LICENSE
+
+source-repository head
+  type:     git
+  location: https://github.com/shinzui/notion-client.git
+
+library
+  default-language:   GHC2024
+  hs-source-dirs:     src
+  build-depends:
+    , aeson                     >=2.2      && <2.3
+    , base                      >=4.15.0.0 && <5
+    , base16-bytestring         >=1.0      && <1.1
+    , bytestring                >=0.11     && <0.13
+    , containers                >=0.6      && <0.8
+    , cryptohash-sha256         >=0.11     && <0.12
+    , filepath                  >=1.4      && <1.6
+    , http-api-data             >=0.6      && <0.7
+    , http-client-tls           >=0.3      && <0.4
+    , servant                   >=0.20     && <0.21
+    , servant-client            >=0.20     && <0.21
+    , servant-multipart-api     >=0.12     && <0.13
+    , servant-multipart-client  >=0.12     && <0.13
+    , text                      >=2.0      && <2.2
+    , time                      >=1.11     && <1.15
+    , time-compat               >=1.9      && <1.10
+    , unordered-containers      >=0.2      && <0.3
+    , vector                    >=0.13     && <0.14
+
+  exposed-modules:
+    Notion.V1
+    Notion.V1.Blocks
+    Notion.V1.Comments
+    Notion.V1.Common
+    Notion.V1.Databases
+    Notion.V1.DataSources
+    Notion.V1.Error
+    Notion.V1.ListOf
+    Notion.V1.Pages
+    Notion.V1.Pagination
+    Notion.V1.RichText
+    Notion.V1.Search
+    Notion.V1.Users
+    Notion.V1.Webhooks
+
+  other-modules:      Notion.Prelude
+  default-extensions:
+    DuplicateRecordFields
+    OverloadedLabels
+    OverloadedStrings
+    RecordWildCards
+
+  ghc-options:        -Wall
+
+test-suite tasty
+  default-language:   GHC2024
+  type:               exitcode-stdio-1.0
+  hs-source-dirs:     tasty
+  main-is:            Main.hs
+  default-extensions:
+    DuplicateRecordFields
+    OverloadedLabels
+    OverloadedStrings
+    RecordWildCards
+
+  build-depends:
+    , aeson
+    , base
+    , http-client
+    , http-client-tls
+    , notion-client
+    , servant-client
+    , tasty
+    , tasty-hunit
+    , text
+
+  ghc-options:        -Wall
+
+executable notion-client-example
+  default-language:   GHC2024
+  hs-source-dirs:     notion-client-example
+  main-is:            Main.hs
+  other-modules:
+    Blocks
+    Console
+    DatabaseDemo
+    PageDemo
+    UserDemo
+
+  default-extensions:
+    DuplicateRecordFields
+    OverloadedLabels
+    OverloadedStrings
+    RecordWildCards
+
+  build-depends:
+    , aeson
+    , base
+    , containers
+    , notion-client
+    , text
+    , unordered-containers
+    , vector
+
+  ghc-options:        -Wall
diff --git a/src/Notion/Prelude.hs b/src/Notion/Prelude.hs
new file mode 100644
--- /dev/null
+++ b/src/Notion/Prelude.hs
@@ -0,0 +1,129 @@
+module Notion.Prelude
+  ( -- * JSON
+    aesonOptions,
+    stripPrefix,
+    labelModifier,
+    parseISO8601,
+
+    -- * Re-exports
+    module Data.Aeson,
+    module Data.ByteString.Lazy,
+    module Data.List.NonEmpty,
+    module Data.Map,
+    module Data.String,
+    module Data.Text,
+    module Data.Time.Clock.POSIX,
+    module Data.Vector,
+    module Data.Void,
+    module Data.Word,
+    module GHC.Generics,
+    module Numeric.Natural,
+    module Servant.API,
+    module Servant.Multipart.API,
+    module Web.HttpApiData,
+  )
+where
+
+import Data.Aeson
+  ( FromJSON (..),
+    Options (..),
+    SumEncoding (..),
+    ToJSON (..),
+    Value (..),
+    genericParseJSON,
+    genericToJSON,
+  )
+import Data.Aeson qualified as Aeson
+import Data.Aeson.Types qualified as Aeson
+import Data.ByteString.Lazy (ByteString)
+import Data.Char qualified as Char
+import Data.List qualified as List
+import Data.List.NonEmpty (NonEmpty (..))
+import Data.Map (Map)
+-- Non-qualified function to use in implementation
+
+import Data.Maybe (fromMaybe)
+import Data.String (IsString (..))
+import Data.Text (Text, pack, unpack)
+import Data.Time.Clock qualified as Clock
+import Data.Time.Clock.POSIX (POSIXTime)
+import Data.Time.Clock.POSIX qualified as Time
+import Data.Time.Format.ISO8601 qualified as ISO8601
+import Data.Time.LocalTime (ZonedTime, zonedTimeToUTC)
+import Data.Vector (Vector)
+import Data.Void (Void)
+import Data.Word (Word8)
+import GHC.Generics (Generic)
+import Numeric.Natural (Natural)
+import Servant.API
+  ( Accept (..),
+    Capture,
+    Delete,
+    Get,
+    Header',
+    JSON,
+    MimeUnrender (..),
+    OctetStream,
+    Patch,
+    Post,
+    QueryParam,
+    ReqBody,
+    Required,
+    Strict,
+    (:<|>) (..),
+    (:>),
+  )
+import Servant.Multipart.API
+  ( FileData (..),
+    Input (..),
+    MultipartData (..),
+    MultipartForm,
+    Tmp,
+    ToMultipart (..),
+  )
+import Web.HttpApiData (ToHttpApiData (..))
+
+dropTrailingUnderscore :: String -> String
+dropTrailingUnderscore "_" = ""
+dropTrailingUnderscore "" = ""
+dropTrailingUnderscore (c : cs) = c : dropTrailingUnderscore cs
+
+-- | Convert camelCase to snake_case and handle trailing underscores
+-- e.g., "createdTime" -> "created_time", "type_" -> "type"
+camelToSnake :: String -> String
+camelToSnake = \case
+  [] -> []
+  (c : cs) -> Char.toLower c : go cs
+  where
+    go [] = []
+    go (c : cs)
+      | Char.isUpper c = '_' : Char.toLower c : go cs
+      | otherwise = c : go cs
+
+labelModifier :: String -> String
+labelModifier = camelToSnake . dropTrailingUnderscore
+
+stripPrefix :: String -> String -> String
+stripPrefix prefix string = labelModifier suffix
+  where
+    suffix = fromMaybe string (List.stripPrefix prefix string)
+
+aesonOptions :: Options
+aesonOptions =
+  Aeson.defaultOptions
+    { fieldLabelModifier = labelModifier,
+      constructorTagModifier = labelModifier,
+      omitNothingFields = True
+    }
+
+-- | Parse an ISO8601 timestamp string to POSIXTime
+-- Handles both UTC format (Z suffix) and timezone offset format (+00:00)
+parseISO8601 :: Text -> Aeson.Parser POSIXTime
+parseISO8601 text =
+  case (ISO8601.iso8601ParseM str :: Maybe Clock.UTCTime) of
+    Just utcTime -> return $ Time.utcTimeToPOSIXSeconds utcTime
+    Nothing -> case (ISO8601.iso8601ParseM str :: Maybe ZonedTime) of
+      Just zonedTime -> return $ Time.utcTimeToPOSIXSeconds (zonedTimeToUTC zonedTime)
+      Nothing -> fail $ "Failed to parse ISO8601 timestamp: " <> str
+  where
+    str = unpack text
diff --git a/src/Notion/V1.hs b/src/Notion/V1.hs
new file mode 100644
--- /dev/null
+++ b/src/Notion/V1.hs
@@ -0,0 +1,191 @@
+-- | Notion API v1
+--
+-- Example usage:
+--
+-- @
+-- module Main where
+--
+-- import Notion.V1
+-- import Notion.V1.Pages
+-- import Data.Text qualified as Text
+-- import System.Environment qualified as Environment
+--
+-- main :: IO ()
+-- main = do
+--     token <- Environment.getEnv "NOTION_TOKEN"
+--
+--     clientEnv <- getClientEnv "https://api.notion.com/v1"
+--
+--     let Methods{ retrievePage } = makeMethods clientEnv (Text.pack token)
+--
+--     page <- retrievePage "page-id-here"
+--
+--     print page
+-- @
+module Notion.V1
+  ( -- * Methods
+    getClientEnv,
+    makeMethods,
+    Methods (..),
+
+    -- * Servant
+    API,
+  )
+where
+
+import Control.Exception qualified as Exception
+import Data.Proxy (Proxy (..))
+import Data.Text qualified as Text
+import Network.HTTP.Client.TLS qualified as TLS
+import Notion.Prelude
+import Notion.V1.Blocks (BlockID, BlockObject)
+import Notion.V1.Blocks qualified as Blocks
+import Notion.V1.Comments (CommentObject)
+import Notion.V1.Comments qualified as Comments
+import Notion.V1.Common (ParentID)
+import Notion.V1.DataSources (DataSourceID, DataSourceObject)
+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.ListOf (ListOf (..))
+import Notion.V1.Pages (CreatePage, PageID, PageObject, UpdatePage)
+import Notion.V1.Pages qualified as Pages
+import Notion.V1.Search (SearchRequest)
+import Notion.V1.Search qualified as Search
+import Notion.V1.Users (UserID, UserObject)
+import Notion.V1.Users qualified as Users
+import Servant.Client (ClientEnv)
+import Servant.Client qualified as Client
+import Servant.Multipart.Client ()
+
+-- | Convenient utility to get a `ClientEnv` for the most common use case
+getClientEnv ::
+  -- | Base URL for API
+  Text ->
+  IO ClientEnv
+getClientEnv baseUrlText = do
+  baseUrl <- Client.parseBaseUrl (Text.unpack baseUrlText)
+  manager <- TLS.newTlsManager
+  pure (Client.mkClientEnv manager baseUrl)
+
+-- | Get a record of API methods after providing an API token
+makeMethods ::
+  ClientEnv ->
+  -- | API token
+  Text ->
+  Methods
+makeMethods clientEnv token = Methods {..}
+  where
+    notionVersion = "2025-09-03" -- Notion API version with data source support
+    -- If you experience 400 errors, check for updated versions at
+    -- https://developers.notion.com/reference/versioning
+    ( ( createDatabase
+          :<|> retrieveDatabase
+          :<|> updateDatabase
+          :<|> queryDatabase
+        )
+        :<|> ( retrieveDataSource
+                 :<|> createDataSource
+                 :<|> updateDataSource
+                 :<|> queryDataSource
+               )
+        :<|> ( retrievePage
+                 :<|> createPage
+                 :<|> updatePage
+               )
+        :<|> ( retrieveBlock
+                 :<|> updateBlock
+                 :<|> retrieveBlockChildren_
+                 :<|> appendBlockChildren
+                 :<|> deleteBlock
+               )
+        :<|> ( retrieveUser
+                 :<|> listUsers_
+                 :<|> retrieveMyUser
+               )
+        :<|> search_
+        :<|> ( createComment
+                 :<|> listComments_
+               )
+      ) = Client.hoistClient @API Proxy run (Client.client @API Proxy) authorization notionVersion
+
+    authorization = "Bearer " <> token
+
+    run :: Client.ClientM a -> IO a
+    run clientM = do
+      result <- Client.runClientM clientM clientEnv
+      case result of
+        Left exception -> Exception.throwIO exception
+        Right a -> return a
+
+    -- Keep the ListOf structure
+    listBlockChildren = retrieveBlockChildren_
+    listUsers = listUsers_
+    listComments = listComments_
+    search = search_
+
+-- | API methods
+data Methods = Methods
+  { -- \* Databases
+    createDatabase :: CreateDatabase -> IO DatabaseObject,
+    retrieveDatabase :: DatabaseID -> IO DatabaseObject,
+    updateDatabase :: DatabaseID -> UpdateDatabase -> IO DatabaseObject,
+    queryDatabase :: DatabaseID -> QueryDatabase -> IO (ListOf PageObject),
+    -- \* Data Sources
+    retrieveDataSource :: DataSourceID -> IO DataSourceObject,
+    createDataSource :: DataSources.CreateDataSource -> IO DataSourceObject,
+    updateDataSource :: DataSourceID -> DataSources.UpdateDataSource -> IO DataSourceObject,
+    queryDataSource :: DataSourceID -> DataSources.QueryDataSource -> IO (ListOf PageObject),
+    -- \* Pages
+    createPage :: CreatePage -> IO PageObject,
+    retrievePage :: PageID -> IO PageObject,
+    updatePage :: PageID -> UpdatePage -> IO PageObject,
+    -- \* Blocks
+    retrieveBlock :: BlockID -> IO BlockObject,
+    updateBlock :: BlockID -> Blocks.BlockContent -> IO BlockObject,
+    listBlockChildren ::
+      ParentID ->
+      Maybe Natural ->
+      -- \^ page_size
+      Maybe Text ->
+      -- \^ start_cursor
+      IO (ListOf BlockObject),
+    appendBlockChildren :: ParentID -> Blocks.AppendBlockChildren -> IO (ListOf BlockObject),
+    deleteBlock :: BlockID -> IO BlockObject,
+    -- \* Users
+    retrieveUser :: UserID -> IO UserObject,
+    listUsers ::
+      Maybe Natural ->
+      -- \^ page_size
+      Maybe Text ->
+      -- \^ start_cursor
+      IO (ListOf UserObject),
+    retrieveMyUser :: IO UserObject,
+    -- \* Search
+    search :: SearchRequest -> IO (ListOf Value),
+    -- \* Comments
+    createComment :: Comments.CreateComment -> IO CommentObject,
+    -- | List comments on a block or page. To list comments on a page, use the page ID
+    -- as the block_id parameter (pages are blocks in Notion).
+    listComments ::
+      Maybe BlockID ->
+      -- \^ block_id (use page ID here for page comments)
+      Maybe Text ->
+      -- \^ start_cursor
+      Maybe Natural ->
+      -- \^ page_size
+      IO (ListOf CommentObject)
+  }
+
+-- | Servant API
+type API =
+  Header' [Required, Strict] "Authorization" Text
+    :> Header' [Required, Strict] "Notion-Version" Text
+    :> ( Databases.API
+           :<|> DataSources.API
+           :<|> Pages.API
+           :<|> Blocks.API
+           :<|> Users.API
+           :<|> Search.API
+           :<|> Comments.API
+       )
diff --git a/src/Notion/V1/Blocks.hs b/src/Notion/V1/Blocks.hs
new file mode 100644
--- /dev/null
+++ b/src/Notion/V1/Blocks.hs
@@ -0,0 +1,95 @@
+-- | @\/v1\/blocks@
+module Notion.V1.Blocks
+  ( -- * Main types
+    BlockID,
+    BlockObject (..),
+    BlockContent (..),
+    AppendBlockChildren (..),
+
+    -- * Servant
+    API,
+  )
+where
+
+import Data.Aeson ((.:))
+import Data.Aeson.Key qualified as Key
+import Notion.Prelude
+import Notion.V1.Common (BlockID, ObjectType (..), Parent)
+import Notion.V1.ListOf (ListOf)
+import Notion.V1.Users (UserReference)
+import Prelude hiding (id)
+
+-- | Notion block object
+data BlockObject = BlockObject
+  { id :: BlockID,
+    parent :: Parent,
+    createdTime :: POSIXTime,
+    lastEditedTime :: POSIXTime,
+    createdBy :: UserReference,
+    lastEditedBy :: UserReference,
+    hasChildren :: Bool,
+    archived :: Bool,
+    type_ :: Text,
+    content :: Value,
+    object :: ObjectType
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON BlockObject where
+  parseJSON = \case
+    Object o -> do
+      id <- o .: "id"
+      parent <- o .: "parent"
+      createdTimeStr <- o .: "created_time"
+      createdTime <- parseISO8601 createdTimeStr
+      lastEditedTimeStr <- o .: "last_edited_time"
+      lastEditedTime <- parseISO8601 lastEditedTimeStr
+      createdBy <- o .: "created_by"
+      lastEditedBy <- o .: "last_edited_by"
+      hasChildren <- o .: "has_children"
+      archived <- o .: "archived"
+      type_ <- o .: "type"
+      -- Content is stored under a field named after the block type (e.g., "heading_1", "paragraph")
+      content <- o .: Key.fromText type_
+      object <- o .: "object"
+      return BlockObject {..}
+    _ -> fail "Expected object for BlockObject"
+
+-- | Block content for update
+newtype BlockContent = BlockContent
+  { content :: Value
+  }
+  deriving stock (Generic, Show)
+
+instance ToJSON BlockContent where
+  toJSON = genericToJSON aesonOptions
+
+-- | Append children to a block
+newtype AppendBlockChildren = AppendBlockChildren
+  { children :: Vector Value
+  }
+  deriving stock (Generic, Show)
+
+instance ToJSON AppendBlockChildren where
+  toJSON = genericToJSON aesonOptions
+
+-- | Servant API
+type API =
+  "blocks"
+    :> ( Capture "block_id" BlockID
+           :> Get '[JSON] BlockObject
+           :<|> Capture "block_id" BlockID
+           :> ReqBody '[JSON] BlockContent
+           :> Patch '[JSON] BlockObject
+           :<|> Capture "block_id" BlockID
+           :> "children"
+           :> QueryParam "page_size" Natural
+           :> QueryParam "start_cursor" Text
+           :> Get '[JSON] (ListOf BlockObject)
+           :<|> Capture "block_id" BlockID
+           :> "children"
+           :> ReqBody '[JSON] AppendBlockChildren
+           :> Patch '[JSON] (ListOf BlockObject)
+           :<|> Capture "block_id" BlockID
+           :> Delete '[JSON] BlockObject
+       )
diff --git a/src/Notion/V1/Comments.hs b/src/Notion/V1/Comments.hs
new file mode 100644
--- /dev/null
+++ b/src/Notion/V1/Comments.hs
@@ -0,0 +1,103 @@
+-- | @\/v1\/comments@
+module Notion.V1.Comments
+  ( -- * Main types
+    CommentID,
+    CommentObject (..),
+    CommentAttachment (..),
+    CommentDisplayName (..),
+    CreateComment (..),
+
+    -- * Servant
+    API,
+  )
+where
+
+import Data.Aeson ((.:), (.:?))
+import Notion.Prelude
+import Notion.V1.Common (BlockID, ExternalFile, File, ObjectType (..), Parent, UUID)
+import Notion.V1.ListOf (ListOf)
+import Notion.V1.RichText (RichText)
+import Notion.V1.Users (UserReference)
+import Prelude hiding (id)
+
+-- | Comment ID
+type CommentID = UUID
+
+-- | Comment attachment (files attached to comments)
+data CommentAttachment = CommentAttachment
+  { name :: Text,
+    type_ :: Text,
+    external :: Maybe ExternalFile,
+    file :: Maybe File
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON CommentAttachment where
+  parseJSON = genericParseJSON aesonOptions {fieldLabelModifier = \s -> if s == "type_" then "type" else labelModifier s}
+
+-- | Comment display name (custom display name for comments)
+data CommentDisplayName = CommentDisplayName
+  { type_ :: Text,
+    emoji :: Maybe Text,
+    displayName :: Maybe Text
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON CommentDisplayName where
+  parseJSON = genericParseJSON aesonOptions {fieldLabelModifier = \s -> if s == "type_" then "type" else labelModifier s}
+
+-- | Notion comment object
+data CommentObject = CommentObject
+  { id :: CommentID,
+    parent :: Parent,
+    discussionId :: UUID,
+    createdTime :: POSIXTime,
+    lastEditedTime :: POSIXTime,
+    createdBy :: UserReference,
+    richText :: Vector RichText,
+    attachments :: Maybe (Vector CommentAttachment),
+    displayName :: Maybe CommentDisplayName,
+    object :: ObjectType
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON CommentObject where
+  parseJSON = \case
+    Object o -> do
+      id <- o .: "id"
+      parent <- o .: "parent"
+      discussionId <- o .: "discussion_id"
+      createdTimeStr <- o .: "created_time"
+      createdTime <- parseISO8601 createdTimeStr
+      lastEditedTimeStr <- o .: "last_edited_time"
+      lastEditedTime <- parseISO8601 lastEditedTimeStr
+      createdBy <- o .: "created_by"
+      richText <- o .: "rich_text"
+      attachments <- o .:? "attachments"
+      displayName <- o .:? "display_name"
+      object <- o .: "object"
+      return CommentObject {..}
+    _ -> fail "Expected object for CommentObject"
+
+-- | Create comment request
+data CreateComment = CreateComment
+  { parent :: Parent,
+    richText :: Vector RichText,
+    discussionId :: Maybe UUID
+  }
+  deriving stock (Generic, Show)
+
+instance ToJSON CreateComment where
+  toJSON = genericToJSON aesonOptions
+
+-- | Servant API
+-- Note: To list comments on a page, use the page ID as block_id (pages are blocks in Notion)
+type API =
+  "comments"
+    :> ( ReqBody '[JSON] CreateComment
+           :> Post '[JSON] CommentObject
+           :<|> QueryParam "block_id" BlockID
+           :> QueryParam "start_cursor" Text
+           :> QueryParam "page_size" Natural
+           :> Get '[JSON] (ListOf CommentObject)
+       )
diff --git a/src/Notion/V1/Common.hs b/src/Notion/V1/Common.hs
new file mode 100644
--- /dev/null
+++ b/src/Notion/V1/Common.hs
@@ -0,0 +1,191 @@
+-- | Common Notion API types
+module Notion.V1.Common
+  ( -- * Common types
+    UUID,
+    BlockID,
+    ObjectType (..),
+    Parent (..),
+    ParentID,
+    Color (..),
+    Icon (..),
+    Cover (..),
+    File (..),
+    ExternalFile (..),
+  )
+where
+
+import Data.Aeson (Object, object, (.:), (.:?), (.=))
+import Data.Aeson.Types (Parser)
+import Data.Foldable (asum)
+import Notion.Prelude
+
+-- | UUID type for Notion resource IDs
+newtype UUID = UUID {text :: Text}
+  deriving newtype (Eq, FromJSON, IsString, Show, ToHttpApiData, ToJSON)
+
+-- | Block ID
+type BlockID = UUID
+
+-- | Possible Notion object types
+data ObjectType
+  = Database
+  | DataSource
+  | Page
+  | Block
+  | User
+  | Comment
+  deriving stock (Eq, Show, Generic)
+
+instance FromJSON ObjectType where
+  parseJSON = genericParseJSON aesonOptions
+
+instance ToJSON ObjectType where
+  toJSON = genericToJSON aesonOptions
+
+-- | Parent object that can be a database, data source, page, block, or workspace
+data Parent
+  = DatabaseParent {databaseId :: UUID}
+  | DataSourceParent {dataSourceId :: UUID}
+  | PageParent {pageId :: UUID}
+  | BlockParent {blockId :: UUID}
+  | WorkspaceParent {workspace :: Bool}
+  deriving stock (Generic, Show)
+
+instance FromJSON Parent where
+  parseJSON = \case
+    Object o -> do
+      mParentType <- o .:? "type"
+      case mParentType of
+        Just parentType -> parseByType parentType o
+        Nothing -> parseByKey o
+    _ -> fail "Expected object for Parent"
+    where
+      parseByType :: Text -> Object -> Parser Parent
+      parseByType = \case
+        "database" -> fmap DatabaseParent . (.: "database_id")
+        "database_id" -> fmap DatabaseParent . (.: "database_id")
+        "data_source" -> fmap DataSourceParent . (.: "data_source_id")
+        "data_source_id" -> fmap DataSourceParent . (.: "data_source_id")
+        "page" -> fmap PageParent . (.: "page_id")
+        "page_id" -> fmap PageParent . (.: "page_id")
+        "block" -> fmap BlockParent . (.: "block_id")
+        "block_id" -> fmap BlockParent . (.: "block_id")
+        "workspace" -> fmap WorkspaceParent . (.: "workspace")
+        other -> \_ -> fail $ "Unknown parent type: " <> unpack other
+
+      parseByKey :: Object -> Parser Parent
+      parseByKey o =
+        asum
+          [ DataSourceParent <$> o .: "data_source_id",
+            DatabaseParent <$> o .: "database_id",
+            PageParent <$> o .: "page_id",
+            BlockParent <$> o .: "block_id",
+            WorkspaceParent <$> o .: "workspace"
+          ]
+
+instance ToJSON Parent where
+  toJSON (DatabaseParent dbId) = object ["type" .= ("database_id" :: Text), "database_id" .= dbId]
+  toJSON (DataSourceParent dsId) = object ["type" .= ("data_source_id" :: Text), "data_source_id" .= dsId]
+  toJSON (PageParent pId) = object ["type" .= ("page_id" :: Text), "page_id" .= pId]
+  toJSON (BlockParent bId) = object ["type" .= ("block_id" :: Text), "block_id" .= bId]
+  toJSON (WorkspaceParent ws) = object ["type" .= ("workspace" :: Text), "workspace" .= ws]
+
+-- | Unified parent ID type
+type ParentID = UUID
+
+-- | Notion color options
+data Color
+  = Default
+  | Gray
+  | Brown
+  | Orange
+  | Yellow
+  | Green
+  | Blue
+  | Purple
+  | Pink
+  | Red
+  | GrayBackground
+  | BrownBackground
+  | OrangeBackground
+  | YellowBackground
+  | GreenBackground
+  | BlueBackground
+  | PurpleBackground
+  | PinkBackground
+  | RedBackground
+  deriving stock (Eq, Show, Generic)
+
+instance FromJSON Color where
+  parseJSON = genericParseJSON aesonOptions
+
+instance ToJSON Color where
+  toJSON = genericToJSON aesonOptions
+
+-- | Icon object for pages/databases
+data Icon
+  = EmojiIcon {emoji :: Text}
+  | FileIcon {file :: File}
+  | ExternalIcon {external :: ExternalFile}
+  deriving stock (Generic, Show)
+
+instance FromJSON Icon where
+  parseJSON = \case
+    Object o -> do
+      iconType <- o .: "type"
+      case iconType of
+        "emoji" -> EmojiIcon <$> o .: "emoji"
+        "file" -> FileIcon <$> o .: "file"
+        "external" -> ExternalIcon <$> o .: "external"
+        _ -> fail $ "Unknown icon type: " <> unpack iconType
+    _ -> fail "Expected object for Icon"
+
+instance ToJSON Icon where
+  toJSON (EmojiIcon emoji) = object ["type" .= ("emoji" :: Text), "emoji" .= emoji]
+  toJSON (FileIcon file) = object ["type" .= ("file" :: Text), "file" .= file]
+  toJSON (ExternalIcon external) = object ["type" .= ("external" :: Text), "external" .= external]
+
+-- | Cover object for pages/databases
+data Cover
+  = FileCover {file :: File}
+  | ExternalCover {external :: ExternalFile}
+  deriving stock (Generic, Show)
+
+instance FromJSON Cover where
+  parseJSON = \case
+    Object o -> do
+      coverType <- o .: "type"
+      case coverType of
+        "file" -> FileCover <$> o .: "file"
+        "external" -> ExternalCover <$> o .: "external"
+        _ -> fail $ "Unknown cover type: " <> unpack coverType
+    _ -> fail "Expected object for Cover"
+
+instance ToJSON Cover where
+  toJSON (FileCover file) = object ["type" .= ("file" :: Text), "file" .= file]
+  toJSON (ExternalCover external) = object ["type" .= ("external" :: Text), "external" .= external]
+
+-- | Internal file object
+data File = File
+  { url :: Text,
+    expiryTime :: Maybe POSIXTime
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON File where
+  parseJSON = genericParseJSON aesonOptions
+
+instance ToJSON File where
+  toJSON = genericToJSON aesonOptions
+
+-- | External file object
+newtype ExternalFile = ExternalFile
+  { url :: Text
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON ExternalFile where
+  parseJSON = genericParseJSON aesonOptions
+
+instance ToJSON ExternalFile where
+  toJSON = genericToJSON aesonOptions
diff --git a/src/Notion/V1/DataSources.hs b/src/Notion/V1/DataSources.hs
new file mode 100644
--- /dev/null
+++ b/src/Notion/V1/DataSources.hs
@@ -0,0 +1,133 @@
+-- | @\/v1\/data_sources@
+--
+-- Data sources represent the schema and content within a database.
+-- A single database can contain multiple data sources (API version 2025-09-03+).
+module Notion.V1.DataSources
+  ( -- * Main types
+    DataSourceID,
+    DataSourceObject (..),
+    CreateDataSource (..),
+    UpdateDataSource (..),
+    QueryDataSource (..),
+
+    -- * Servant
+    API,
+  )
+where
+
+import Data.Aeson ((.:), (.:?))
+import Notion.Prelude
+import Notion.V1.Common (Cover, Icon, ObjectType, Parent, UUID)
+import Notion.V1.ListOf (ListOf)
+import Notion.V1.Pages (PageObject)
+import Notion.V1.RichText (RichText)
+import Notion.V1.Users (UserReference)
+import Prelude hiding (id)
+
+-- | Data source ID
+type DataSourceID = UUID
+
+-- | Notion data source object
+data DataSourceObject = DataSourceObject
+  { id :: DataSourceID,
+    createdTime :: POSIXTime,
+    lastEditedTime :: POSIXTime,
+    createdBy :: UserReference,
+    lastEditedBy :: UserReference,
+    title :: Vector RichText,
+    description :: Vector RichText,
+    properties :: Value,
+    url :: Text,
+    parent :: Parent,
+    databaseParent :: Maybe Parent,
+    archived :: Bool,
+    isInline :: Maybe Bool,
+    inTrash :: Maybe Bool,
+    publicUrl :: Maybe Text,
+    icon :: Maybe Icon,
+    cover :: Maybe Cover,
+    object :: ObjectType
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON DataSourceObject where
+  parseJSON = \case
+    Object o -> do
+      id <- o .: "id"
+      createdTimeStr <- o .: "created_time"
+      createdTime <- parseISO8601 createdTimeStr
+      lastEditedTimeStr <- o .: "last_edited_time"
+      lastEditedTime <- parseISO8601 lastEditedTimeStr
+      createdBy <- o .: "created_by"
+      lastEditedBy <- o .: "last_edited_by"
+      title <- o .: "title"
+      description <- o .: "description"
+      properties <- o .: "properties"
+      url <- o .: "url"
+      parent <- o .: "parent"
+      databaseParent <- o .:? "database_parent"
+      archived <- o .: "archived"
+      isInline <- o .:? "is_inline"
+      inTrash <- o .:? "in_trash"
+      publicUrl <- o .:? "public_url"
+      icon <- o .:? "icon"
+      cover <- o .:? "cover"
+      object <- o .: "object"
+      return DataSourceObject {..}
+    _ -> fail "Expected object for DataSourceObject"
+
+-- | Create data source request
+data CreateDataSource = CreateDataSource
+  { parent :: Parent,
+    properties :: Value,
+    title :: Maybe (Vector RichText),
+    icon :: Maybe Icon
+  }
+  deriving stock (Generic, Show)
+
+instance ToJSON CreateDataSource where
+  toJSON = genericToJSON aesonOptions
+
+-- | Update data source request
+data UpdateDataSource = UpdateDataSource
+  { title :: Maybe (Vector RichText),
+    icon :: Maybe Icon,
+    properties :: Maybe Value,
+    inTrash :: Maybe Bool,
+    archived :: Maybe Bool,
+    parent :: Maybe Parent
+  }
+  deriving stock (Generic, Show)
+
+instance ToJSON UpdateDataSource where
+  toJSON = genericToJSON aesonOptions
+
+-- | Query data source request
+data QueryDataSource = QueryDataSource
+  { filter :: Maybe Value,
+    sorts :: Maybe [Value],
+    startCursor :: Maybe Text,
+    pageSize :: Maybe Natural,
+    archived :: Maybe Bool,
+    inTrash :: Maybe Bool
+  }
+  deriving stock (Generic, Show)
+
+instance ToJSON QueryDataSource where
+  toJSON = genericToJSON aesonOptions
+
+-- | Servant API
+type API =
+  "data_sources"
+    :> ( Capture "data_source_id" DataSourceID
+           :> Get '[JSON] DataSourceObject
+           :<|> ReqBody '[JSON] CreateDataSource
+           :> Post '[JSON] DataSourceObject
+           :<|> Capture "data_source_id" DataSourceID
+           :> ReqBody '[JSON] UpdateDataSource
+           :> Patch '[JSON] DataSourceObject
+           :<|> Capture "data_source_id" DataSourceID
+           :> "query"
+           :> ReqBody '[JSON] QueryDataSource
+           :> Post '[JSON] (ListOf PageObject)
+       )
diff --git a/src/Notion/V1/Databases.hs b/src/Notion/V1/Databases.hs
new file mode 100644
--- /dev/null
+++ b/src/Notion/V1/Databases.hs
@@ -0,0 +1,168 @@
+-- | @\/v1\/databases@
+module Notion.V1.Databases
+  ( -- * Main types
+    DatabaseID,
+    DatabaseObject (..),
+    DataSource (..),
+    InitialDataSource (..),
+    CreateDatabase (..),
+    UpdateDatabase (..),
+    QueryDatabase (..),
+
+    -- * Servant
+    API,
+  )
+where
+
+import Data.Aeson ((.:), (.:?))
+import Notion.Prelude
+import Notion.V1.Common (Cover, Icon, ObjectType (..), Parent, UUID)
+import Notion.V1.ListOf (ListOf)
+import Notion.V1.Pages (PageObject)
+import Notion.V1.RichText (RichText)
+import Notion.V1.Users (UserReference)
+import Prelude hiding (id)
+
+-- | Database ID
+type DatabaseID = UUID
+
+-- | Data source reference within a database (API version 2025-09-03+)
+data DataSource = DataSource
+  { id :: UUID,
+    name :: Text
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON DataSource where
+  parseJSON = genericParseJSON aesonOptions
+
+-- | Notion database object
+--
+-- In API version 2025-09-03, database schema (properties) moved to data sources.
+-- The 'properties' field may be absent; use 'dataSources' and the data source
+-- endpoints to access schema information.
+data DatabaseObject = DatabaseObject
+  { id :: DatabaseID,
+    createdTime :: POSIXTime,
+    lastEditedTime :: POSIXTime,
+    createdBy :: Maybe UserReference,
+    lastEditedBy :: Maybe UserReference,
+    title :: Vector RichText,
+    description :: Maybe (Vector RichText),
+    properties :: Maybe Value,
+    icon :: Maybe Icon,
+    cover :: Maybe Cover,
+    url :: Text,
+    parent :: Parent,
+    archived :: Maybe Bool,
+    isInline :: Maybe Bool,
+    inTrash :: Maybe Bool,
+    isLocked :: Maybe Bool,
+    publicUrl :: Maybe Text,
+    dataSources :: Vector DataSource,
+    object :: ObjectType
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON DatabaseObject where
+  parseJSON = \case
+    Object o -> do
+      id <- o .: "id"
+      createdTimeStr <- o .: "created_time"
+      createdTime <- parseISO8601 createdTimeStr
+      lastEditedTimeStr <- o .: "last_edited_time"
+      lastEditedTime <- parseISO8601 lastEditedTimeStr
+      createdBy <- o .:? "created_by"
+      lastEditedBy <- o .:? "last_edited_by"
+      title <- o .: "title"
+      description <- o .:? "description"
+      properties <- o .:? "properties"
+      icon <- o .:? "icon"
+      cover <- o .:? "cover"
+      url <- o .: "url"
+      parent <- o .: "parent"
+      archived <- o .:? "archived"
+      isInline <- o .:? "is_inline"
+      inTrash <- o .:? "in_trash"
+      isLocked <- o .:? "is_locked"
+      publicUrl <- o .:? "public_url"
+      dataSources <- o .: "data_sources"
+      object <- o .: "object"
+      return DatabaseObject {..}
+    _ -> fail "Expected object for DatabaseObject"
+
+-- | Initial data source configuration for database creation.
+-- Contains the property schema for the database's first data source.
+newtype InitialDataSource = InitialDataSource
+  { properties :: Value
+  }
+  deriving stock (Generic, Show)
+
+instance ToJSON InitialDataSource where
+  toJSON = genericToJSON aesonOptions
+
+-- | Create database request
+--
+-- In API version 2025-09-03, schema is specified via 'initialDataSource'
+-- rather than a top-level @properties@ field.
+data CreateDatabase = CreateDatabase
+  { parent :: Parent,
+    title :: Vector RichText,
+    initialDataSource :: Maybe InitialDataSource,
+    icon :: Maybe Icon,
+    cover :: Maybe Cover,
+    description :: Maybe (Vector RichText),
+    isInline :: Maybe Bool
+  }
+  deriving stock (Generic, Show)
+
+instance ToJSON CreateDatabase where
+  toJSON = genericToJSON aesonOptions
+
+-- | Update database request
+--
+-- In API version 2025-09-03, schema updates (properties) are handled via
+-- the Update Data Source API ('Notion.V1.DataSources.UpdateDataSource').
+-- This endpoint only handles database-level attributes.
+data UpdateDatabase = UpdateDatabase
+  { title :: Maybe (Vector RichText),
+    icon :: Maybe Icon,
+    cover :: Maybe Cover,
+    description :: Maybe (Vector RichText),
+    archived :: Maybe Bool,
+    isInline :: Maybe Bool,
+    inTrash :: Maybe Bool,
+    parent :: Maybe Parent
+  }
+  deriving stock (Generic, Show)
+
+instance ToJSON UpdateDatabase where
+  toJSON = genericToJSON aesonOptions
+
+-- | Query database request
+data QueryDatabase = QueryDatabase
+  { filter :: Maybe Value,
+    sorts :: Maybe [Value],
+    startCursor :: Maybe Text,
+    pageSize :: Maybe Natural
+  }
+  deriving stock (Generic, Show)
+
+instance ToJSON QueryDatabase where
+  toJSON = genericToJSON aesonOptions
+
+-- | Servant API
+type API =
+  "databases"
+    :> ( ReqBody '[JSON] CreateDatabase
+           :> Post '[JSON] DatabaseObject
+           :<|> Capture "database_id" DatabaseID
+           :> Get '[JSON] DatabaseObject
+           :<|> Capture "database_id" DatabaseID
+           :> ReqBody '[JSON] UpdateDatabase
+           :> Patch '[JSON] DatabaseObject
+           :<|> Capture "database_id" DatabaseID
+           :> "query"
+           :> ReqBody '[JSON] QueryDatabase
+           :> Post '[JSON] (ListOf PageObject)
+       )
diff --git a/src/Notion/V1/Error.hs b/src/Notion/V1/Error.hs
new file mode 100644
--- /dev/null
+++ b/src/Notion/V1/Error.hs
@@ -0,0 +1,21 @@
+-- | Error handling for Notion API
+module Notion.V1.Error
+  ( -- * Error types
+    NotionError (..),
+  )
+where
+
+import Notion.Prelude
+
+-- | Notion API error response
+data NotionError = NotionError
+  { object :: Text,
+    status :: Natural,
+    code :: Text,
+    message :: Text,
+    details :: Maybe Value
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON NotionError where
+  parseJSON = genericParseJSON aesonOptions
diff --git a/src/Notion/V1/ListOf.hs b/src/Notion/V1/ListOf.hs
new file mode 100644
--- /dev/null
+++ b/src/Notion/V1/ListOf.hs
@@ -0,0 +1,30 @@
+-- | The `ListOf` type constructor for handling paginated Notion API responses
+module Notion.V1.ListOf
+  ( -- * Types
+    ListOf (..),
+  )
+where
+
+import Data.Aeson ((.!=), (.:), (.:?))
+import Notion.Prelude
+
+-- | Notion API typically returns paginated results with this structure
+data ListOf a = List
+  { results :: Vector a,
+    nextCursor :: Maybe Text,
+    hasMore :: Bool,
+    type_ :: Maybe Text,
+    object :: Maybe Text
+  }
+  deriving stock (Generic, Show)
+
+instance (FromJSON a) => FromJSON (ListOf a) where
+  parseJSON = \case
+    Object o -> do
+      results <- o .: "results"
+      nextCursor <- o .:? "next_cursor"
+      hasMore <- o .:? "has_more" .!= False
+      type_ <- o .:? "type"
+      object <- o .:? "object"
+      return $ List {..}
+    _ -> fail "Expected object for ListOf"
diff --git a/src/Notion/V1/Pages.hs b/src/Notion/V1/Pages.hs
new file mode 100644
--- /dev/null
+++ b/src/Notion/V1/Pages.hs
@@ -0,0 +1,217 @@
+-- | @\/v1\/pages@
+module Notion.V1.Pages
+  ( -- * Main types
+    PageID,
+    PageObject (..),
+    CreatePage (..),
+    UpdatePage (..),
+    PageProperties,
+    PropertyValue (..),
+    PropertyItem (..),
+    PropertyValueType (..),
+    SelectOption (..),
+    mkCreatePage,
+    mkUpdatePage,
+
+    -- * Servant
+    API,
+  )
+where
+
+import Data.Aeson (Object, Value, (.:), (.:?), (.=))
+import Data.Aeson qualified as Aeson
+import Data.Aeson.KeyMap qualified as KeyMap
+import Data.Char qualified as Char
+import Data.String (fromString)
+import Notion.Prelude
+import Notion.V1.Common (Cover, Icon, ObjectType (..), Parent, UUID)
+import Notion.V1.Users (UserReference)
+
+-- | Page ID
+type PageID = UUID
+
+-- | Notion page object
+data PageObject = PageObject
+  { id :: PageID,
+    createdTime :: POSIXTime,
+    lastEditedTime :: POSIXTime,
+    createdBy :: UserReference,
+    lastEditedBy :: UserReference,
+    cover :: Maybe Cover,
+    icon :: Maybe Icon,
+    parent :: Parent,
+    archived :: Bool,
+    inTrash :: Bool,
+    properties :: Map Text PropertyItem,
+    url :: Text,
+    object :: ObjectType
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON PageObject where
+  parseJSON = \case
+    Object o -> do
+      id <- o .: "id"
+      createdTimeStr <- o .: "created_time"
+      createdTime <- parseISO8601 createdTimeStr
+      lastEditedTimeStr <- o .: "last_edited_time"
+      lastEditedTime <- parseISO8601 lastEditedTimeStr
+      createdBy <- o .: "created_by"
+      lastEditedBy <- o .: "last_edited_by"
+      cover <- o .:? "cover"
+      icon <- o .:? "icon"
+      parent <- o .: "parent"
+      archived <- o .: "archived"
+      inTrash <- o .: "in_trash"
+      properties <- o .: "properties"
+      url <- o .: "url"
+      object <- o .: "object"
+      return PageObject {..}
+    _ -> fail "Expected object for PageObject"
+
+-- | Create a page request
+data CreatePage = CreatePage
+  { parent :: Parent,
+    properties :: PageProperties,
+    children :: Maybe (Vector Value),
+    icon :: Maybe Icon,
+    cover :: Maybe Cover
+  }
+  deriving stock (Generic, Show)
+
+instance ToJSON CreatePage where
+  toJSON = genericToJSON aesonOptions
+
+-- | Smart constructor for 'CreatePage' with required fields
+mkCreatePage :: Parent -> PageProperties -> CreatePage
+mkCreatePage parent properties =
+  CreatePage
+    { parent,
+      properties,
+      children = Nothing,
+      icon = Nothing,
+      cover = Nothing
+    }
+
+-- | Update a page request
+data UpdatePage = UpdatePage
+  { properties :: PageProperties,
+    archived :: Maybe Bool,
+    icon :: Maybe Icon,
+    cover :: Maybe Cover
+  }
+  deriving stock (Generic, Show)
+
+instance ToJSON UpdatePage where
+  toJSON = genericToJSON aesonOptions
+
+-- | Smart constructor for 'UpdatePage' with required fields
+mkUpdatePage :: PageProperties -> UpdatePage
+mkUpdatePage properties =
+  UpdatePage
+    { properties,
+      archived = Nothing,
+      icon = Nothing,
+      cover = Nothing
+    }
+
+-- | 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 = genericParseJSON aesonOptions {fieldLabelModifier = \s -> if s == "type_" then "type" else labelModifier s}
+
+-- | 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 (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
+
+-- | Servant API
+type API =
+  "pages"
+    :> ( Capture "page_id" PageID
+           :> Get '[JSON] PageObject
+           :<|> ReqBody '[JSON] CreatePage
+           :> Post '[JSON] PageObject
+           :<|> Capture "page_id" PageID
+           :> ReqBody '[JSON] UpdatePage
+           :> Patch '[JSON] PageObject
+       )
diff --git a/src/Notion/V1/Pagination.hs b/src/Notion/V1/Pagination.hs
new file mode 100644
--- /dev/null
+++ b/src/Notion/V1/Pagination.hs
@@ -0,0 +1,27 @@
+-- | Pagination utilities for Notion API
+module Notion.V1.Pagination
+  ( -- * Pagination types
+    PaginationParams (..),
+    defaultPaginationParams,
+  )
+where
+
+import Notion.Prelude
+
+-- | Pagination parameters for Notion API requests
+data PaginationParams = PaginationParams
+  { pageSize :: Maybe Natural,
+    startCursor :: Maybe Text
+  }
+  deriving stock (Generic, Show)
+
+instance ToJSON PaginationParams where
+  toJSON = genericToJSON aesonOptions
+
+-- | Default pagination parameters
+defaultPaginationParams :: PaginationParams
+defaultPaginationParams =
+  PaginationParams
+    { pageSize = Nothing,
+      startCursor = Nothing
+    }
diff --git a/src/Notion/V1/RichText.hs b/src/Notion/V1/RichText.hs
new file mode 100644
--- /dev/null
+++ b/src/Notion/V1/RichText.hs
@@ -0,0 +1,207 @@
+-- | Rich Text objects in Notion API
+module Notion.V1.RichText
+  ( -- * Rich Text types
+    RichText (..),
+    RichTextContent (..),
+    TextContent (..),
+    MentionContent (..),
+    EquationContent (..),
+    Annotations (..),
+    defaultAnnotations,
+    Link (..),
+    Date (..),
+    TimeZone (..),
+  )
+where
+
+import Data.Aeson (object, (.:), (.:?), (.=))
+import Notion.Prelude
+import Notion.V1.Common (Color (..), UUID)
+
+-- | Rich text object in Notion
+data RichText = RichText
+  { plainText :: Text,
+    href :: Maybe Text,
+    annotations :: Annotations,
+    type_ :: Text,
+    content :: RichTextContent
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON RichText where
+  parseJSON = \case
+    Object o -> do
+      plainText <- o .: "plain_text"
+      href <- o .:? "href"
+      annotations <- o .: "annotations"
+      type_ <- o .: "type"
+      content <- case type_ of
+        "text" -> TextContentWrapper <$> o .: "text"
+        "mention" -> MentionContentWrapper <$> o .: "mention"
+        "equation" -> EquationContentWrapper <$> o .: "equation"
+        other -> fail $ "Unknown rich text type: " <> unpack other
+      return RichText {..}
+    _ -> fail "Expected object for RichText"
+
+instance ToJSON RichText where
+  toJSON RichText {..} =
+    object $
+      [ "type" .= type_,
+        "plain_text" .= plainText,
+        "annotations" .= annotations
+      ]
+        ++ maybe [] (\h -> ["href" .= h]) href
+        ++ case content of
+          TextContentWrapper tc -> ["text" .= tc]
+          MentionContentWrapper mc -> ["mention" .= mc]
+          EquationContentWrapper ec -> ["equation" .= ec]
+
+-- | Text content
+data TextContent = TextContent
+  { content :: Text,
+    link :: Maybe Link
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON TextContent where
+  parseJSON = genericParseJSON aesonOptions
+
+instance ToJSON TextContent where
+  toJSON = genericToJSON aesonOptions
+
+-- | Mention content
+--
+-- Notion mentions have a @type@ discriminator and the content nested under
+-- the corresponding field name. For example:
+--
+-- @
+-- { "type": "user", "user": { "id": "..." } }
+-- @
+data MentionContent
+  = UserMention {user :: UUID}
+  | PageMention {page :: UUID}
+  | DatabaseMention {database :: UUID}
+  | DateMention {date :: Date}
+  | LinkPreviewMention {url :: Text}
+  deriving stock (Generic, Show)
+
+instance FromJSON MentionContent where
+  parseJSON = \case
+    Object o -> do
+      mentionType <- o .: "type"
+      case mentionType of
+        "user" -> do
+          userObj <- o .: "user"
+          UserMention <$> parseIdField userObj
+        "page" -> do
+          pageObj <- o .: "page"
+          PageMention <$> parseIdField pageObj
+        "database" -> do
+          dbObj <- o .: "database"
+          DatabaseMention <$> parseIdField dbObj
+        "date" -> DateMention <$> o .: "date"
+        "link_preview" -> do
+          lpObj <- o .: "link_preview"
+          LinkPreviewMention <$> parseUrlField lpObj
+        other -> fail $ "Unknown mention type: " <> unpack other
+    _ -> fail "Expected object for MentionContent"
+    where
+      parseIdField = \case
+        Object o -> o .: "id"
+        _ -> fail "Expected object with id field"
+      parseUrlField = \case
+        Object o -> o .: "url"
+        _ -> fail "Expected object with url field"
+
+instance ToJSON MentionContent where
+  toJSON = \case
+    UserMention uid ->
+      object ["type" .= ("user" :: Text), "user" .= object ["id" .= uid]]
+    PageMention pid ->
+      object ["type" .= ("page" :: Text), "page" .= object ["id" .= pid]]
+    DatabaseMention dbid ->
+      object ["type" .= ("database" :: Text), "database" .= object ["id" .= dbid]]
+    DateMention d ->
+      object ["type" .= ("date" :: Text), "date" .= d]
+    LinkPreviewMention u ->
+      object ["type" .= ("link_preview" :: Text), "link_preview" .= object ["url" .= u]]
+
+-- | Equation content
+newtype EquationContent = EquationContent
+  { expression :: Text
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON EquationContent where
+  parseJSON = genericParseJSON aesonOptions
+
+instance ToJSON EquationContent where
+  toJSON = genericToJSON aesonOptions
+
+-- | Content of a rich text object
+data RichTextContent
+  = TextContentWrapper TextContent
+  | MentionContentWrapper MentionContent
+  | EquationContentWrapper EquationContent
+  deriving stock (Generic, Show)
+
+-- | Text annotations
+data Annotations = Annotations
+  { bold :: Bool,
+    italic :: Bool,
+    strikethrough :: Bool,
+    underline :: Bool,
+    code :: Bool,
+    color :: Color
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON Annotations where
+  parseJSON = genericParseJSON aesonOptions
+
+instance ToJSON Annotations where
+  toJSON = genericToJSON aesonOptions
+
+-- | Default annotations
+defaultAnnotations :: Annotations
+defaultAnnotations =
+  Annotations
+    { bold = False,
+      italic = False,
+      strikethrough = False,
+      underline = False,
+      code = False,
+      color = Default
+    }
+
+-- | Link object
+newtype Link = Link
+  { url :: Text
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON Link where
+  parseJSON = genericParseJSON aesonOptions
+
+instance ToJSON Link where
+  toJSON = genericToJSON aesonOptions
+
+-- | Date object
+data Date = Date
+  { start :: Text,
+    end :: Maybe Text,
+    timeZone :: Maybe TimeZone
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON Date where
+  parseJSON = genericParseJSON aesonOptions
+
+instance ToJSON Date where
+  toJSON = genericToJSON aesonOptions
+
+-- | Time zone
+newtype TimeZone = TimeZone
+  { text :: Text
+  }
+  deriving newtype (FromJSON, IsString, Show, ToJSON)
diff --git a/src/Notion/V1/Search.hs b/src/Notion/V1/Search.hs
new file mode 100644
--- /dev/null
+++ b/src/Notion/V1/Search.hs
@@ -0,0 +1,141 @@
+-- | @\/v1\/search@
+module Notion.V1.Search
+  ( -- * Main types
+    SearchRequest (..),
+    _SearchRequest,
+    SearchSortDirection (..),
+    SearchSort (..),
+    SearchFilter (..),
+    SearchObjectType (..),
+
+    -- * Response parsing
+    SearchResult (..),
+    parseSearchResults,
+
+    -- * Convenience constructors
+    pageFilter,
+    dataSourceFilter,
+
+    -- * Servant
+    API,
+  )
+where
+
+import Data.Aeson qualified as Aeson
+import Data.Vector qualified as Vector
+import Notion.Prelude
+import Notion.V1.DataSources (DataSourceObject)
+import Notion.V1.ListOf (ListOf (..))
+import Notion.V1.Pages (PageObject)
+
+-- | Search request
+data SearchRequest = SearchRequest
+  { query :: Maybe Text,
+    sort :: Maybe SearchSort,
+    filter :: Maybe SearchFilter,
+    startCursor :: Maybe Text,
+    pageSize :: Maybe Natural
+  }
+  deriving stock (Generic, Show)
+
+instance ToJSON SearchRequest where
+  toJSON = genericToJSON aesonOptions
+
+-- | Default search request
+_SearchRequest :: SearchRequest
+_SearchRequest =
+  SearchRequest
+    { query = Nothing,
+      sort = Nothing,
+      filter = Nothing,
+      startCursor = Nothing,
+      pageSize = Nothing
+    }
+
+-- | Search sort direction
+data SearchSortDirection
+  = Ascending
+  | Descending
+  deriving stock (Generic, Show)
+
+instance ToJSON SearchSortDirection where
+  toJSON = genericToJSON aesonOptions
+
+-- | Search sort
+data SearchSort = SearchSort
+  { direction :: SearchSortDirection,
+    timestamp :: Text
+  }
+  deriving stock (Generic, Show)
+
+instance ToJSON SearchSort where
+  toJSON = genericToJSON aesonOptions
+
+-- | Object types supported by the search filter.
+-- In API version 2025-09-03, the search API filters by @page@ or @data_source@.
+data SearchObjectType
+  = SearchPage
+  | SearchDataSource
+  deriving stock (Eq, Show, Generic)
+
+instance ToJSON SearchObjectType where
+  toJSON SearchPage = Aeson.String "page"
+  toJSON SearchDataSource = Aeson.String "data_source"
+
+instance FromJSON SearchObjectType where
+  parseJSON = Aeson.withText "SearchObjectType" $ \case
+    "page" -> pure SearchPage
+    "data_source" -> pure SearchDataSource
+    other -> fail $ "Unknown search object type: " <> unpack other
+
+-- | Search filter
+data SearchFilter = SearchFilter
+  { value :: SearchObjectType,
+    property :: Text
+  }
+  deriving stock (Generic, Show)
+
+instance ToJSON SearchFilter where
+  toJSON = genericToJSON aesonOptions
+
+-- | Create a filter to search only for pages
+pageFilter :: SearchFilter
+pageFilter = SearchFilter {value = SearchPage, property = "object"}
+
+-- | Create a filter to search only for data sources
+dataSourceFilter :: SearchFilter
+dataSourceFilter = SearchFilter {value = SearchDataSource, property = "object"}
+
+-- | Servant API
+type API =
+  "search"
+    :> ReqBody '[JSON] SearchRequest
+    :> Post '[JSON] (ListOf Aeson.Value)
+
+-- * Response parsing
+
+-- | A search result can be either a page or a data source
+data SearchResult
+  = PageResult PageObject
+  | DataSourceResult DataSourceObject
+  deriving stock (Show)
+
+instance FromJSON SearchResult where
+  parseJSON v = do
+    obj <- Aeson.parseJSON v
+    objectType <- obj Aeson..: "object"
+    case objectType of
+      "page" -> PageResult <$> Aeson.parseJSON v
+      "data_source" -> DataSourceResult <$> Aeson.parseJSON v
+      other -> fail $ "Unknown object type in search result: " <> other
+
+-- | Parse raw search results into typed 'SearchResult' values.
+-- Results that fail to parse are silently dropped.
+parseSearchResults :: ListOf Aeson.Value -> Vector SearchResult
+parseSearchResults listOf =
+  Vector.mapMaybe parseOne (results listOf)
+  where
+    parseOne :: Aeson.Value -> Maybe SearchResult
+    parseOne v = case Aeson.fromJSON v of
+      Aeson.Success r -> Just r
+      Aeson.Error _ -> Nothing
diff --git a/src/Notion/V1/Users.hs b/src/Notion/V1/Users.hs
new file mode 100644
--- /dev/null
+++ b/src/Notion/V1/Users.hs
@@ -0,0 +1,107 @@
+{-# LANGUAGE LambdaCase #-}
+
+-- | @\/v1\/users@
+module Notion.V1.Users
+  ( -- * Main types
+    UserID,
+    UserObject (..),
+    UserType (..),
+    PersonUser (..),
+    BotUser (..),
+    UserReference (..),
+
+    -- * Servant
+    API,
+  )
+where
+
+import Data.Aeson.Types ((.:))
+import Notion.Prelude
+import Notion.V1.Common (ObjectType (..), UUID)
+import Notion.V1.ListOf (ListOf)
+
+-- | User ID
+type UserID = UUID
+
+-- | Notion user object
+data UserObject = UserObject
+  { id :: UserID,
+    name :: Maybe Text,
+    avatarUrl :: Maybe Text,
+    type_ :: UserType,
+    person :: Maybe PersonUser,
+    bot :: Maybe BotUser,
+    object :: ObjectType
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON UserObject where
+  parseJSON = genericParseJSON aesonOptions {fieldLabelModifier = \s -> if s == "type_" then "type" else labelModifier s}
+
+-- | User type
+data UserType
+  = Person
+  | Bot
+  deriving stock (Generic, Show)
+
+instance FromJSON UserType where
+  parseJSON = genericParseJSON aesonOptions
+
+-- | Person user
+newtype PersonUser = PersonUser
+  { email :: Text
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON PersonUser where
+  parseJSON = genericParseJSON aesonOptions
+
+-- | Bot user
+data BotUser = BotUser
+  { owner :: Maybe UserOwner,
+    workspaceName :: Maybe Text
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON BotUser where
+  parseJSON = genericParseJSON aesonOptions
+
+-- | User owner
+data UserOwner
+  = UserOwner {type_ :: Text, user :: UserID}
+  | WorkspaceOwner {type_ :: Text, workspace :: Bool}
+  deriving stock (Generic, Show)
+
+instance FromJSON UserOwner where
+  parseJSON = \case
+    Object o -> do
+      ownerType <- o .: "type"
+      case ownerType of
+        "user" -> UserOwner ownerType <$> (o .: "user")
+        "workspace" -> WorkspaceOwner ownerType <$> (o .: "workspace")
+        _ -> fail $ "Unknown owner type: " <> unpack ownerType
+    _ -> fail "Expected object for UserOwner"
+
+-- | Simple user reference objects that appear in created_by and last_edited_by fields
+data UserReference = UserReference
+  { id :: UserID,
+    object :: Text
+  }
+  deriving stock (Generic, Show)
+
+instance FromJSON UserReference where
+  parseJSON = \case
+    Object o -> UserReference <$> o .: "id" <*> o .: "object"
+    _ -> fail "Expected object for UserReference"
+
+-- | Servant API
+type API =
+  "users"
+    :> ( Capture "user_id" UserID
+           :> Get '[JSON] UserObject
+           :<|> QueryParam "page_size" Natural
+           :> QueryParam "start_cursor" Text
+           :> Get '[JSON] (ListOf UserObject)
+           :<|> "me"
+           :> Get '[JSON] UserObject
+       )
diff --git a/src/Notion/V1/Webhooks.hs b/src/Notion/V1/Webhooks.hs
new file mode 100644
--- /dev/null
+++ b/src/Notion/V1/Webhooks.hs
@@ -0,0 +1,340 @@
+-- | Notion Webhook types and utilities
+--
+-- This module provides types for handling incoming webhook events from Notion.
+-- Webhook subscriptions are created via the Notion integration UI, not via API.
+--
+-- Usage:
+--
+-- @
+-- import Notion.V1.Webhooks
+-- import Data.Aeson (eitherDecode)
+--
+-- handleWebhook :: ByteString -> Text -> Text -> IO ()
+-- handleWebhook body signature verificationToken = do
+--   -- Verify the signature
+--   case verifySignature verificationToken body signature of
+--     False -> error "Invalid signature"
+--     True -> do
+--       -- Parse the event
+--       case eitherDecode body of
+--         Left err -> error err
+--         Right event -> processEvent event
+--
+-- processEvent :: WebhookEvent -> IO ()
+-- processEvent event = case event.type_ of
+--   PageCreated -> putStrLn "Page created!"
+--   CommentCreated -> putStrLn "Comment created!"
+--   _ -> putStrLn "Other event"
+-- @
+module Notion.V1.Webhooks
+  ( -- * Event types
+    WebhookEvent (..),
+    EventType (..),
+    WebhookEntity (..),
+    EntityType (..),
+    Author (..),
+    AccessibleBy (..),
+
+    -- * Verification
+    VerificationPayload (..),
+    verifySignature,
+    computeSignature,
+  )
+where
+
+import Crypto.Hash.SHA256 qualified as SHA256
+import Data.Aeson (object, (.:), (.:?), (.=))
+import Data.Bits (xor, (.|.))
+import Data.ByteString (ByteString)
+import Data.ByteString qualified as BS
+import Data.ByteString.Base16 qualified as Base16
+import Data.Text.Encoding qualified as Text
+import Notion.Prelude hiding (ByteString)
+import Notion.V1.Common (UUID (..))
+
+-- | Webhook event types supported by Notion
+data EventType
+  = -- | Page events
+    PageCreated
+  | PageDeleted
+  | PageUndeleted
+  | PagePropertiesUpdated
+  | PageContentUpdated
+  | PageMoved
+  | PageLocked
+  | PageUnlocked
+  | -- | Database events (deprecated as of 2025-09-03)
+    DatabaseCreated
+  | DatabaseDeleted
+  | DatabaseUndeleted
+  | DatabaseContentUpdated
+  | DatabaseSchemaUpdated
+  | DatabaseMoved
+  | -- | Data source events (new in 2025-09-03)
+    DataSourceCreated
+  | DataSourceDeleted
+  | DataSourceUndeleted
+  | DataSourceContentUpdated
+  | DataSourceSchemaUpdated
+  | DataSourceMoved
+  | -- | Comment events
+    CommentCreated
+  | CommentUpdated
+  | CommentDeleted
+  | -- | Unknown event type (for forward compatibility)
+    UnknownEvent Text
+  deriving stock (Eq, Show, Generic)
+
+instance FromJSON EventType where
+  parseJSON = \case
+    String "page.created" -> pure PageCreated
+    String "page.deleted" -> pure PageDeleted
+    String "page.undeleted" -> pure PageUndeleted
+    String "page.properties_updated" -> pure PagePropertiesUpdated
+    String "page.content_updated" -> pure PageContentUpdated
+    String "page.moved" -> pure PageMoved
+    String "page.locked" -> pure PageLocked
+    String "page.unlocked" -> pure PageUnlocked
+    String "database.created" -> pure DatabaseCreated
+    String "database.deleted" -> pure DatabaseDeleted
+    String "database.undeleted" -> pure DatabaseUndeleted
+    String "database.content_updated" -> pure DatabaseContentUpdated
+    String "database.schema_updated" -> pure DatabaseSchemaUpdated
+    String "database.moved" -> pure DatabaseMoved
+    String "data_source.created" -> pure DataSourceCreated
+    String "data_source.deleted" -> pure DataSourceDeleted
+    String "data_source.undeleted" -> pure DataSourceUndeleted
+    String "data_source.content_updated" -> pure DataSourceContentUpdated
+    String "data_source.schema_updated" -> pure DataSourceSchemaUpdated
+    String "data_source.moved" -> pure DataSourceMoved
+    String "comment.created" -> pure CommentCreated
+    String "comment.updated" -> pure CommentUpdated
+    String "comment.deleted" -> pure CommentDeleted
+    String other -> pure $ UnknownEvent other
+    _ -> fail "Expected string for EventType"
+
+instance ToJSON EventType where
+  toJSON = \case
+    PageCreated -> String "page.created"
+    PageDeleted -> String "page.deleted"
+    PageUndeleted -> String "page.undeleted"
+    PagePropertiesUpdated -> String "page.properties_updated"
+    PageContentUpdated -> String "page.content_updated"
+    PageMoved -> String "page.moved"
+    PageLocked -> String "page.locked"
+    PageUnlocked -> String "page.unlocked"
+    DatabaseCreated -> String "database.created"
+    DatabaseDeleted -> String "database.deleted"
+    DatabaseUndeleted -> String "database.undeleted"
+    DatabaseContentUpdated -> String "database.content_updated"
+    DatabaseSchemaUpdated -> String "database.schema_updated"
+    DatabaseMoved -> String "database.moved"
+    DataSourceCreated -> String "data_source.created"
+    DataSourceDeleted -> String "data_source.deleted"
+    DataSourceUndeleted -> String "data_source.undeleted"
+    DataSourceContentUpdated -> String "data_source.content_updated"
+    DataSourceSchemaUpdated -> String "data_source.schema_updated"
+    DataSourceMoved -> String "data_source.moved"
+    CommentCreated -> String "comment.created"
+    CommentUpdated -> String "comment.updated"
+    CommentDeleted -> String "comment.deleted"
+    UnknownEvent t -> String t
+
+-- | Entity types in webhook events
+data EntityType
+  = PageEntity
+  | DatabaseEntity
+  | DataSourceEntity
+  | CommentEntity
+  | UnknownEntityType Text
+  deriving stock (Eq, Show, Generic)
+
+instance FromJSON EntityType where
+  parseJSON = \case
+    String "page" -> pure PageEntity
+    String "database" -> pure DatabaseEntity
+    String "data_source" -> pure DataSourceEntity
+    String "comment" -> pure CommentEntity
+    String other -> pure $ UnknownEntityType other
+    _ -> fail "Expected string for EntityType"
+
+instance ToJSON EntityType where
+  toJSON = \case
+    PageEntity -> String "page"
+    DatabaseEntity -> String "database"
+    DataSourceEntity -> String "data_source"
+    CommentEntity -> String "comment"
+    UnknownEntityType t -> String t
+
+-- | Entity that triggered the webhook event
+data WebhookEntity = WebhookEntity
+  { id :: UUID,
+    type_ :: EntityType
+  }
+  deriving stock (Eq, Show, Generic)
+
+instance FromJSON WebhookEntity where
+  parseJSON = \case
+    Object o -> do
+      id <- o .: "id"
+      type_ <- o .: "type"
+      pure WebhookEntity {..}
+    _ -> fail "Expected object for WebhookEntity"
+
+instance ToJSON WebhookEntity where
+  toJSON WebhookEntity {..} =
+    object
+      [ "id" .= id,
+        "type" .= type_
+      ]
+
+-- | Author who triggered the event (user or bot)
+data Author = Author
+  { id :: UUID,
+    type_ :: Text
+  }
+  deriving stock (Eq, Show, Generic)
+
+instance FromJSON Author where
+  parseJSON = \case
+    Object o -> do
+      id <- o .: "id"
+      type_ <- o .: "type"
+      pure Author {..}
+    _ -> fail "Expected object for Author"
+
+instance ToJSON Author where
+  toJSON Author {..} =
+    object
+      [ "id" .= id,
+        "type" .= type_
+      ]
+
+-- | User or bot with access to the affected entity
+data AccessibleBy = AccessibleBy
+  { id :: UUID,
+    type_ :: Text
+  }
+  deriving stock (Eq, Show, Generic)
+
+instance FromJSON AccessibleBy where
+  parseJSON = \case
+    Object o -> do
+      id <- o .: "id"
+      type_ <- o .: "type"
+      pure AccessibleBy {..}
+    _ -> fail "Expected object for AccessibleBy"
+
+instance ToJSON AccessibleBy where
+  toJSON AccessibleBy {..} =
+    object
+      [ "id" .= id,
+        "type" .= type_
+      ]
+
+-- | A webhook event sent by Notion to your endpoint
+data WebhookEvent = WebhookEvent
+  { -- | Unique identifier for this event
+    id :: UUID,
+    -- | When the event occurred (ISO 8601)
+    timestamp :: POSIXTime,
+    -- | Workspace where the event originated
+    workspaceId :: UUID,
+    -- | Associated webhook subscription
+    subscriptionId :: UUID,
+    -- | Integration that owns the subscription
+    integrationId :: UUID,
+    -- | Type of event
+    type_ :: EventType,
+    -- | Users/bots who triggered the action
+    authors :: Vector Author,
+    -- | Users/bots with access to the entity
+    accessibleBy :: Vector AccessibleBy,
+    -- | Delivery attempt number (1-8)
+    attemptNumber :: Int,
+    -- | Entity that triggered the event
+    entity :: WebhookEntity,
+    -- | Event-specific data (varies by event type)
+    data_ :: Maybe Value
+  }
+  deriving stock (Show, Generic)
+
+instance FromJSON WebhookEvent where
+  parseJSON = \case
+    Object o -> do
+      id <- o .: "id"
+      timestampText <- o .: "timestamp"
+      timestamp <- parseISO8601 timestampText
+      workspaceId <- o .: "workspace_id"
+      subscriptionId <- o .: "subscription_id"
+      integrationId <- o .: "integration_id"
+      type_ <- o .: "type"
+      authors <- o .: "authors"
+      accessibleBy <- o .: "accessible_by"
+      attemptNumber <- o .: "attempt_number"
+      entity <- o .: "entity"
+      data_ <- o .:? "data"
+      pure WebhookEvent {..}
+    _ -> fail "Expected object for WebhookEvent"
+
+instance ToJSON WebhookEvent where
+  toJSON = genericToJSON aesonOptions
+
+-- | Verification payload sent by Notion when setting up a webhook
+-- Your endpoint should receive this and confirm the token in the Notion UI
+data VerificationPayload = VerificationPayload
+  { verificationToken :: Text
+  }
+  deriving stock (Eq, Show, Generic)
+
+instance FromJSON VerificationPayload where
+  parseJSON = genericParseJSON aesonOptions
+
+instance ToJSON VerificationPayload where
+  toJSON = genericToJSON aesonOptions
+
+-- | Compute HMAC-SHA256 signature for webhook payload validation
+--
+-- The signature is computed as: sha256=HMAC-SHA256(verification_token, body)
+computeSignature ::
+  -- | Verification token (used as HMAC key)
+  Text ->
+  -- | Request body (minified JSON)
+  ByteString ->
+  -- | Computed signature in "sha256=..." format
+  Text
+computeSignature verificationToken body =
+  "sha256=" <> Text.decodeUtf8 (Base16.encode hmacDigest)
+  where
+    key = Text.encodeUtf8 verificationToken
+    hmacDigest = SHA256.hmac key body
+
+-- | Verify webhook signature from X-Notion-Signature header
+--
+-- Uses constant-time comparison to prevent timing attacks.
+--
+-- Example:
+--
+-- @
+-- isValid = verifySignature myToken requestBody headerSignature
+-- @
+verifySignature ::
+  -- | Verification token (from webhook setup)
+  Text ->
+  -- | Request body (minified JSON as received)
+  ByteString ->
+  -- | Signature from X-Notion-Signature header
+  Text ->
+  -- | True if signature is valid
+  Bool
+verifySignature verificationToken body headerSignature =
+  constantTimeCompare expected actual
+  where
+    expected = Text.encodeUtf8 $ computeSignature verificationToken body
+    actual = Text.encodeUtf8 headerSignature
+
+-- | Constant-time comparison to prevent timing attacks
+constantTimeCompare :: ByteString -> ByteString -> Bool
+constantTimeCompare a b
+  | BS.length a /= BS.length b = False
+  | otherwise = 0 == BS.foldl' (\acc w -> acc .|. w) 0 (BS.packZipWith xor a b)
diff --git a/tasty/Main.hs b/tasty/Main.hs
new file mode 100644
--- /dev/null
+++ b/tasty/Main.hs
@@ -0,0 +1,70 @@
+module Main where
+
+import Data.Text qualified as Text
+import Notion.V1
+import Notion.V1.ListOf (ListOf (..))
+import Notion.V1.Search (SearchRequest (..))
+import Notion.V1.Users (UserObject (..))
+import System.Environment qualified as Environment
+import Test.Tasty
+import Test.Tasty.HUnit
+
+main :: IO ()
+main = do
+  -- These tests expect a NOTION_TOKEN environment variable
+  -- and only run against the actual Notion API
+  defaultMain =<< tests
+
+tests :: IO TestTree
+tests = do
+  mToken <- lookupEnv "NOTION_TOKEN"
+
+  case mToken of
+    Nothing ->
+      pure $
+        testGroup
+          "Notion API Tests"
+          [ testCase "No API token found" $
+              assertFailure "Set NOTION_TOKEN environment variable to run tests"
+          ]
+    Just token -> do
+      clientEnv <- getClientEnv "https://api.notion.com/v1"
+      let methods = makeMethods clientEnv (Text.pack token)
+
+      pure $
+        testGroup
+          "Notion API Tests"
+          [ testCase "Retrieve current user" $ testRetrieveCurrentUser methods,
+            testCase "List users" $ testListUsers methods,
+            testCase "Timestamp parsing works" $ testSearchAPI methods
+          ]
+
+testRetrieveCurrentUser :: Methods -> Assertion
+testRetrieveCurrentUser Methods {retrieveMyUser} = do
+  user <- retrieveMyUser
+  let UserObject {id = userId} = user
+  -- Using Show instance of UUID to verify it's not empty
+  assertBool "User object should have an ID" (show userId /= "")
+
+testListUsers :: Methods -> Assertion
+testListUsers Methods {listUsers} = do
+  users <- listUsers Nothing Nothing
+  let userCount = length $ results users
+  assertBool "Should have at least one user" (userCount > 0)
+
+testSearchAPI :: Methods -> Assertion
+testSearchAPI Methods {search} = do
+  let searchParams =
+        Notion.V1.Search.SearchRequest
+          { query = Just "test",
+            sort = Nothing,
+            filter = Nothing,
+            startCursor = Nothing,
+            pageSize = Nothing
+          }
+  _ <- search searchParams
+  -- If this test runs without errors, it means timestamp parsing works
+  assertBool "Search should run without timestamp parsing errors" True
+
+lookupEnv :: String -> IO (Maybe String)
+lookupEnv var = Environment.lookupEnv var
