Notion API Client for Haskell
A type-safe Haskell client for the Notion API (version 2026-03-11).
Features
- Type-safe API bindings using Servant
- Comprehensive coverage of Notion API endpoints
- Typed pages, databases, data sources, blocks, users, and other published API objects
- Simple client interface with sensible defaults
- Automatic retries of rate-limited and overloaded requests, honoring
retry-after
- Typed error codes with request IDs for support
- OAuth login, automatic token refresh and URL-to-ID helpers
Installation
For this release, use notion-client 0.10.x with notion-client-effectful 0.4.x
if you need the effectful adapter. Add to your .cabal file:
build-depends: notion-client >=0.10 && <0.11
Or add to package.yaml:
dependencies:
- notion-client >=0.10 && <0.11
Upgrading from 0.9
Fixed-token constructors (makeMethods, makeMethodsWith, makeMethodsWithEnv)
keep their signatures. Automatic refresh is opt-in through
makeMethodsWithTokenSource; Methods fields are unchanged.
Custom HTTP clients must replace RequestContext.contextAuthorization with
contextTokenSource. Prefer requestContextFor for a fixed token or
requestContextWithTokenSource for an installation's source. standardHeaders
now runs in IO and does not renew; use withStandardHeaders around replayable
response acquisition for renewal, keeping response bodies outside retry scopes.
OAuth endpoint failures now use OAuthError. See OAuth for lifecycle
exceptions and the changelog for the full migration.
Usage
Examples assume GHC2024 with OverloadedStrings and DuplicateRecordFields.
Replace placeholder IDs with real IDs from pages shared with your connection.
Here's a simple example of retrieving a Notion page:
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 with typed properties
The target data source must have properties with matching names and types;
this example uses a status property named Status and a select named Priority.
import Notion.V1
import Notion.V1.Common (Parent(..))
import Notion.V1.Pages
import Notion.V1.PropertyValue qualified as PV
import Notion.V1.RichText (RichText)
import Data.Map qualified as Map
import Data.Vector qualified as Vector
createNewPage :: Methods -> RichText -> IO PageObject
createNewPage Methods{createPage} titleRichText = do
let pageProperties = Map.fromList
[ ("title", PV.titleValue (Vector.singleton titleRichText))
, ("Status", PV.statusValue "In Progress")
, ("Priority", PV.selectValue "High")
, ("Due", PV.dateValue "2024-06-01" Nothing)
, ("Score", PV.numberValue 42)
, ("Done", PV.checkboxValue False)
]
newPage = mkCreatePage
(DataSourceParent { dataSourceId = "data-source-id-here", parentDatabaseId = Nothing })
pageProperties
createPage newPage
Reading typed properties
import Data.Map qualified as Map
import Data.Text (Text)
import Notion.V1.Pages (PageObject(..))
import Notion.V1.PropertyValue (PropertyValue(StatusValue), SelectOptionValue(..))
readPageStatus :: PageObject -> Maybe Text
readPageStatus page =
case Map.lookup "Status" (properties page) of
Just (StatusValue _ (Just SelectOptionValue{name = optionName})) -> Just optionName
_ -> Nothing
Serializing page responses
Use the response helpers when exporting API results:
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy (ByteString)
import Notion.V1.Pages (PageObject, pageResponseJSON)
import Notion.V1.PropertyValue (PropertyValue, propertyValueResponseJSON)
encodePageResponse :: PageObject -> ByteString
encodePageResponse = Aeson.encode . pageResponseJSON
encodePropertyResponse :: PropertyValue -> ByteString
encodePropertyResponse = Aeson.encode . propertyValueResponseJSON
These helpers preserve modeled page fields and property IDs, type discriminators,
full users, and nested rollup values. Existing ToJSON instances and create/update
builders keep their mutation encoding. Response helpers preserve modeled data;
they do not preserve unmodeled fields or the original JSON bytes. An empty property
ID is the model's absent-ID sentinel and is omitted, including inside rollups.
Nonempty IDs, including URL-encoded IDs, are emitted unchanged.
RelationValue pid refs hasMore retains has_more as Maybe Bool: Just True
means the reference list is incomplete, Just False means complete, and Nothing
means the flag was absent (or null). No state is inferred from the reference count,
and the encoder does not fetch further references. relationValue keeps its
existing [UUID] -> PropertyValue signature and supplies Nothing for writes.
Creating a page with markdown
import Notion.V1
import Notion.V1.Common (Parent(..))
import Notion.V1.Pages (CreatePage(..), PageObject, mkCreatePage)
createMarkdownPage :: Methods -> IO PageObject
createMarkdownPage Methods{createPage} = do
let newPage = (mkCreatePage
(DataSourceParent { dataSourceId = "data-source-id", parentDatabaseId = Nothing })
mempty)
{ markdown = Just "# Hello\n\nThis page was created with **markdown**." }
createPage newPage
Editing page content with markdown
import Data.Vector qualified as Vector
import Notion.V1
import Notion.V1.Pages
editPage :: Methods -> PageID -> IO PageMarkdown
editPage Methods{updatePageMarkdown} pageId =
updatePageMarkdown pageId $
UpdateContent UpdateContentRequest
{ contentUpdates = Vector.fromList
[ ContentUpdate
{ oldStr = "old text"
, newStr = "new text"
, replaceAllMatches = Nothing
}
]
, allowDeletingContent = Nothing
}
Configuration and retries
makeMethods uses sensible defaults. For control over the API version, base
URL, timeout (default 60 seconds), retries and logging, build a ClientConfig:
import Network.HTTP.Client.TLS (newTlsManager)
import Notion.V1
import Data.Text qualified as Text
import System.Environment (getEnv)
main :: IO ()
main = do
token <- Text.pack <$> getEnv "NOTION_TOKEN"
manager <- newTlsManager
let config = defaultClientConfig {logger = Just stderrLogger, logLevel = LogInfo}
methods = makeMethodsWith config manager token
user <- retrieveMyUser methods
print user
Requests that fail with rate_limited (HTTP 429) or service_overload (529)
are retried up to two times, as are internal_server_error and
service_unavailable for GET and DELETE. A retry-after header sets the
delay; otherwise the delay grows exponentially with jitter. Disable retries with
defaultClientConfig {retryOptions = noRetries}.
Error handling
import Control.Exception (catch)
import Data.Text qualified as Text
import Notion.V1 (Methods(..))
import Notion.V1.Error
import Notion.V1.Pages (PageID)
safeRetrieve :: Methods -> PageID -> IO ()
safeRetrieve Methods{retrievePage} pageId =
(retrievePage pageId >>= print) `catch` \(e :: NotionError) -> case code e of
ObjectNotFound -> putStrLn "No such page"
other ->
putStrLn $ "Notion error: " <> Text.unpack (apiErrorCodeText other)
<> " - " <> Text.unpack (message e)
<> " (request " <> show (requestId e) <> ")"
Besides NotionError, a request can throw UnknownHTTPResponseError (a
non-2xx response that is not a Notion error, such as an HTML page from Notion's
edge proxy; displayException explains it and includes the Cloudflare Ray ID),
RequestTimeoutError, or InvalidPathParameterError (an ID containing ..,
rejected before sending).
OAuth
Public integrations (public connections in Notion's portal) use a browser consent
flow. Create one in the developer portal,
register an exact redirect URI, and keep its OAuth client ID and secret in your
trusted server environment. The client secret and exchange belong on a trusted
backend for distributed desktop or browser apps.
The protocol helpers build the URL, verify the callback, and exchange its code:
import Control.Exception (throwIO)
import Data.Text (Text)
import Network.HTTP.Types.URI (Query)
import Notion.V1.OAuth
import Notion.V1.OAuth.Authorization
beginLogin :: Text -> Text -> IO AuthorizationRequest
beginLogin client redirect = do
freshState <- newOAuthState
pure (AuthorizationRequest client redirect freshState)
-- Run this only after claiming the initiating session's pending request once.
exchangeVerifiedCode :: OAuthMethods -> AuthorizationRequest -> OAuthCode
-> IO OAuthTokenResponse
exchangeVerifiedCode = exchangeAuthorizationCode
verifyCallback :: AuthorizationRequest -> Query -> IO OAuthCode
verifyCallback request query =
either throwIO pure (verifyAuthorizationCallback (state request) query)
Send the browser to authorizationUrl request. Bind the complete request (state,
client ID and exact redirect URI) to the initiating session and expire it after a
short interval. Give the verifier an already-decoded WAI query or parseQuery
output; do not decode twice. After verification, atomically consume the pending
request before exchange. A state-validated AuthorizationDenied also consumes it;
malformed or forged callbacks leave it pending. The pure verifier supplies neither
session storage nor expiry/replay protection. Descriptions are untrusted text.
After a successful exchange, convert with oauthTokensFromResponse now response
and durably save the pair. If that first save fails, retain the received pair and
retry only its save; never exchange the code again. The runnable example below
implements this recovery. Then construct stable methods from your store:
import Notion.V1 (ClientConfig, Methods, makeMethodsWithTokenSource)
import Notion.V1.OAuth (OAuthMethods)
import Notion.V1.OAuth.Refresh
import Servant.Client (ClientEnv)
connectInstallation :: ClientConfig -> ClientEnv -> OAuthMethods -> TokenStore
-> IO (OAuthTokenManager, Methods)
connectInstallation config env oauth store = do
manager <- newOAuthTokenManager oauth store
pure (manager, makeMethodsWithTokenSource config env (tokenManagerSource manager))
TokenStore supplies loadTokens and an idempotent saveTokens; the library also
provides inMemoryTokenStore for volatile use. The manager loads once, renews once
after typed Unauthorized, and persists the replacement before using it.
refreshTokensNow performs explicit refresh. Handle these IO exceptions:
OAuthError: inspect oauthErrorCode; OAuthInvalidClient means client
configuration failed, and OAuthInvalidGrant means a grant was rejected.
OAuth endpoint calls never retry automatically. Default rendering/logging omits
descriptions, raw bodies and headers; explicit metadata is available.
ReauthorizationRequired: start a new login; the refresh token is absent or
rejected. A null replacement refresh token remains absent.
TokenPersistenceError: the manager retains the unsaved replacement and blocks
requests/refresh. Call retryTokenPersistence to save that identical pair.
RefreshOutcomeUnknown: a refresh may have succeeded remotely. Start a new login
and construct a new manager; do not reload the old store and replay its token.
Use one manager per installation. Multiple managers/processes must coordinate the
entire load/refresh/save sequence externally; save-time compare-and-swap is too
late. Store hooks must not re-enter the manager. A crash between remote rotation
and durable save may require another login. currentTokens is for explicit
inspection/recovery and can return unsaved or unusable credentials. There is no
timer refresh because token responses supply no expiry.
RequestContext now carries contextTokenSource; standardHeaders reads current
headers in IO. Non-Servant clients use withStandardHeaders around replayable
response acquisition, with withRetries inside it. Close rejected responses before
throwing their classified error. Keep body consumption and user callbacks outside
both retry layers. Byte and file-backed multipart bodies can be replayed; arbitrary
one-shot streams cannot. OAuth methods retain Basic authentication.
The effectful adapter accepts these same Methods. Final NotionError values use
Error NotionError; OAuth lifecycle and persistence exceptions remain IO exceptions.
URL helpers
import Notion.V1.Common (UUID)
import Notion.V1.Helpers (extractBlockId, extractNotionId)
notionId :: Maybe UUID
notionId = extractNotionId "https://www.notion.so/team/Tasks-abc123def456789012345678901234ab?v=..."
-- Just (UUID "abc123de-f456-7890-1234-5678901234ab")
blockId :: Maybe UUID
blockId = extractBlockId "https://www.notion.so/Page-0123456789abcdef0123456789abcdef#block-fedcba9876543210fedcba9876543210"
-- Just (UUID "fedcba98-7654-3210-fedc-ba9876543210")
import Notion.V1.Pagination (paginateAll)
import Data.Vector (Vector)
import Notion.V1 (Methods(..))
import Notion.V1.DataSources (DataSourceID, QueryDataSource(..), _QueryDataSource)
import Notion.V1.Search (PageOrDataSource)
allPages :: Methods -> DataSourceID -> IO (Vector PageOrDataSource)
allPages methods dsId = paginateAll $ \cursor ->
queryDataSource methods dsId
_QueryDataSource {startCursor = cursor, pageSize = Just 100}
paginateFoldM and paginateForM_ process every item while holding only one
page in memory.
Usage with effectful
Callers that use the effectful
effect system can opt into an Eff-typed surface via the companion
package
notion-client-effectful. Every
Notion.V1.Methods field is re-exposed as a smart constructor of a
Notion effect, and a runNotion interpreter dispatches through a
concrete Methods value. NotionError responses surface via the
Error NotionError effect rather than as IO exceptions.
module Demo where
import Data.Text (Text)
import Data.Text qualified as Text
import Effectful (Eff, runEff, (:>))
import Effectful.Error.Static (Error, runErrorNoCallStack)
import Notion.V1 (getClientEnv, makeMethods)
import Notion.V1.Common (UUID (..))
import Notion.V1.Effectful qualified as NE
import Notion.V1.Error (NotionError)
import System.Environment qualified as Env
demo :: (NE.Notion :> es, Error NotionError :> es) => UUID -> Eff es Text
demo pid = do
page <- NE.retrievePage pid
pure (Text.pack (show page))
main :: IO ()
main = do
token <- Text.pack <$> Env.getEnv "NOTION_TOKEN"
env <- getClientEnv "https://api.notion.com/v1"
let methods = makeMethods env token
result <-
runEff . runErrorNoCallStack @NotionError . NE.runNotion methods $
demo (UUID "00000000-0000-0000-0000-000000000000")
print result
See the companion README
for the full import pattern (qualifying one of the two Notion.V1
modules is required — every smart constructor shares its name with
the matching Methods record selector).
API Coverage
- Databases: Create, retrieve, update, and query databases
- Data Sources: Create, retrieve, update, query data sources; list templates
- Pages: Create (with blocks or markdown), retrieve, update, move pages; retrieve and update page markdown, including background operations
- Blocks: Retrieve, update, append children (with position control), and delete blocks
- Users: List, retrieve users and bot users
- Views: Create, retrieve, update, delete, list, and query database views (all 10 view types)
- Search: Search for pages and data sources
- Comments: Create, retrieve, update, delete and list comments
- File Uploads: Create, upload content, complete, retrieve and list uploads
- Meeting Notes: Create from recordings and query meeting notes
- Async Tasks: Retrieve status and wait for background operations
- Custom Emojis: List workspace custom emojis
- Webhooks: Event types (including view events) and signature verification
- OAuth: Browser login helpers, verified callbacks, automatic token refresh with persistence, revoke and introspect tokens
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:
# 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
OAuth example
The POSIX-only notion-client-oauth-example runs a trusted local confidential
client. In the developer portal,
choose Build → Public connections, create a test connection, and enable the
capabilities your application needs. Register http://localhost:8765/callback
under its OAuth configuration, then copy its OAuth client ID and secret from the
Configuration tab. See Notion's public-connection setup guide.
Set the credentials in your shell (never commit them):
export NOTION_OAUTH_CLIENT_ID=...
export NOTION_OAUTH_CLIENT_SECRET=...
cabal run notion-client-oauth-example -- login
cabal run notion-client-oauth-example -- whoami
cabal run notion-client-oauth-example -- refresh
cabal run notion-client-oauth-example -- whoami
cabal run notion-client-oauth-example -- revoke
For a local checkout using direnv, the same exports can live in gitignored
.envrc.local, which .envrc loads after the development shell. The executable
reads environment variables; it does not load dotenv files itself.
If port 8765 is occupied, register another exact callback URI and set both
NOTION_OAUTH_REDIRECT_URI=http://localhost:8767/callback and
NOTION_OAUTH_LISTEN_PORT=8767. A real public integration accepted this localhost
HTTP callback during live validation on 2026-10-19. Always open the authorization
URL printed by login; visiting /callback directly returns “Invalid callback”.
login prints a consent URL after the listener is ready on 127.0.0.1, validates
state, consumes the callback once, exchanges the code and saves credentials before
reporting success. It expires after ten minutes. Invalid callbacks leave it pending;
a matching-state cancellation reports cancellation without exchanging. whoami
prints the bot name and up to five shared results; an empty search is valid.
refresh prints full-value rotation comparisons as booleans and offers persistence
retry without sending a second grant. revoke removes local credentials only after
confirmed remote success. Subsequent commands require login; no remote token-family
invalidation is assumed.
NOTION_OAUTH_TOKEN_FILE defaults to .notion-oauth-tokens.json. The example requires
an owner-only regular token file in a directory owned by you and not writable by
group or others. It rejects token symlinks, creates private temporary files before
writing, synchronizes and atomically replaces them, and synchronizes the parent
directory. Every command holds <token-file>.lock across load/refresh/save. After
a crash, remove a stale lock directory manually only after checking no example
process is active. Keep the process alive to retry an unsaved pair; stopping it can
require another login. This file store is an example, not a production secret store.
If the portal refuses HTTP localhost redirects, register an HTTPS tunnel's exact
callback URL and set NOTION_OAUTH_REDIRECT_URI to it. Set
NOTION_OAUTH_LISTEN_PORT to its local target port (default 8765). Redirect URIs
must have an absolute path and no query or fragment. The listener still binds
loopback. No framework adapter, PKCE, timer refresh, distributed store or Notion
MCP OAuth is provided. The executable and its POSIX file tests are disabled on
Windows; the core library and ordinary HTTP tests remain portable.
Internal connection credentials
These credentials are for notion-client-example; the OAuth example above uses
a public connection's client ID and secret instead.
- Create an internal connection in the developer portal.
- Copy its API token from Configuration and set
NOTION_TOKEN.
- Share the pages or databases you want to access: open the page's ••• menu,
choose Add connections, and select your connection. See the
authorization guide.
- 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)
Release preparation
The release pair is notion-client-0.10.0.0 and
notion-client-effectful-0.4.0.0, targeting Notion API 2026-03-11 and validated
with GHC 9.12.4. Keep both Cabal versions, the companion's core bound and both
changelogs aligned. Run from the repository root:
cabal build all
env -u NOTION_TOKEN cabal test all --test-show-details=direct
cabal haddock all
cabal check
(cd notion-client-effectful && cabal check)
cabal sdist all
The offline suites currently contain 462 core tests and four companion runtime
tests. The separate live OAuth gate passed login, user-owner decoding, explicit
and reactive rotation, persisted restart, cancellation and revoke/local logout;
its evidence is in plan 19.
After OAuth runtime changes, repeat that sequence with a disposable test
installation. The explicit automatic-refresh harness is:
cabal exec -- runghc -package=notion-client -XGHC2024 -XOverloadedStrings -XDuplicateRecordFields -XRecordWildCards -inotion-client-oauth-example scripts/OAuthLiveCheck.hs
Run it after login/explicit refresh and before revoke. It changes only the
in-memory access token, uses the current saved refresh token, and durably saves
the replacement. It is excluded from offline tests.
Inspect both archives under dist-newstyle/sdist/ before upload. Each must contain
its README, changelog, license and all sources required by its tests and examples;
local environment and token files must be absent. Build and test both extracted
archives together in a fresh Cabal project to catch dependencies on the checkout.
Publish the core package before its companion so the companion's bound resolves.
These preparation commands generate local artifacts; uploading, tagging and
publishing are separate maintainer actions.
License
MIT