Notion Integration
Receive and route Notion webhooks for page and database changes, comments and workspace activity.
Setup
1. Create a Source in Hookbase
curl -X POST https://api.hookbase.app/api/sources \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Notion Production",
"slug": "notion",
"provider": "notion"
}'Leave signingSecret out for now. Notion's key is its verification_token, and Notion only
issues it by sending it to this URL in step 2. There is nothing to copy from a dashboard yet.
Hookbase answers the verification request itself, before any signature check, so
rejectInvalidSignatures does not get in its way.
Save your webhook URL:
https://api.hookbase.app/ingest/{orgSlug}/notion2. Configure the Notion Webhook
- Open your connection's settings in Notion's integrations dashboard and go to the Webhooks tab
- Click + Create a subscription
- Paste your Hookbase ingest URL, select the events you want, and create the subscription
- Notion immediately POSTs a one-time request to that URL whose body is
{"verification_token": "secret_..."}. Hookbase holds the token for 24 hours instead of storing it as an event. Open the source in the dashboard (Edit), where it appears under Notion sent a verification token, and copy it - Back in Notion, click Verify, paste the
verification_token, and click Verify subscription - Once Notion shows the subscription as verified, click Use as signing secret in Hookbase. The
token becomes the source's
signingSecret, the key Notion signs every later event with.
Over the API, the same two steps are:
# Read the token Notion sent
curl https://api.hookbase.app/api/sources/src_.../pending-verification \
-H "Authorization: Bearer whr_your_api_key"
# After Notion accepts it, make it the signing secret
curl -X POST https://api.hookbase.app/api/sources/src_.../pending-verification/adopt \
-H "Authorization: Bearer whr_your_api_key"Both need a key with the write scope, as revealing a signing secret does.
Info
Why Hookbase doesn't adopt the token on its own. The verification request is unsigned, so anyone who knows your ingest URL can post a token to it. Notion's Verify form is what tells a real token from a planted one. If Notion rejects the token, click Resend token in Notion and the new one replaces it in Hookbase. The dashboard shows when and from which IP each token arrived.
- Confirm the connection has access to the pages and databases you want to watch — Notion sends events only for content the connection can see
See Notion's webhooks reference for the event catalog and subscription rules.
3. Create Destinations and Routes
# Create a destination
curl -X POST https://api.hookbase.app/api/destinations \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Docs Indexer", "slug": "notion-indexer", "url": "https://api.myapp.com/webhooks/notion"}'
# Create a route
curl -X POST https://api.hookbase.app/api/routes \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Notion to Indexer", "sourceId": "src_...", "destinationId": "dst_..."}'Signature Verification
Notion signs webhooks with HMAC-SHA256 over the raw request body. The hex digest is sent in the
X-Notion-Signature header behind a sha256= prefix:
X-Notion-Signature: sha256=3f5a1e9c7b2d4086a1c3e5d7f9b0a2c4e6d8f0a1b3c5d7e9f1a3b5c7d9e1f3a5Hookbase verifies this automatically once the source has both fields set:
{
"provider": "notion",
"signingSecret": "secret_..."
}The key is the subscription's verification_token (step 2 above), not the connection's API
token.
There is no timestamp in this scheme, so nothing expires and nothing needs a clock tolerance.
Info
Hookbase hashes the bytes that arrived. Notion's own sample code hashes
JSON.stringify(body) — the re-serialized object rather than the request body. Those two strings
agree only because Notion sends minified JSON. Verifying against the raw body is correct wherever
they agree and correct where they would not.
Once events are arriving with signature_valid: true, set rejectInvalidSignatures: true on the
source to have unverified requests refused with 401 instead of stored.
Common Events
| Event | Description |
|---|---|
page.created | A page was created |
page.content_updated | Page content was updated |
page.properties_updated | Page properties were updated |
page.deleted | A page was deleted |
page.undeleted | A page was restored |
database.created | A database was created |
comment.created | A comment was created |
Page Created
{
"type": "page.created",
"timestamp": "2024-01-15T10:30:00.000Z",
"workspace_id": "ws-abc123",
"data": {
"page": {
"id": "page-def456",
"parent": {
"type": "database_id",
"database_id": "db-ghi789"
},
"properties": {
"Name": {
"title": [
{
"text": {
"content": "New Feature Spec"
}
}
]
},
"Status": {
"select": {
"name": "Draft"
}
}
},
"created_time": "2024-01-15T10:30:00.000Z",
"created_by": {
"id": "user-jkl012"
}
}
}
}Page Properties Updated
{
"type": "page.properties_updated",
"timestamp": "2024-01-15T11:00:00.000Z",
"workspace_id": "ws-abc123",
"data": {
"page": {
"id": "page-def456"
},
"updated_properties": [
"Status"
]
}
}Info
Notion's update events name what changed, not the new value. A page.properties_updated event
lists the changed property names; reading the values means calling Notion's API for the page.
Transform Examples
A javascript transform receives the parsed payload and returns the body Hookbase delivers.
Flatten for an Index
function transform(payload) {
const page = payload.data && payload.data.page ? payload.data.page : {};
return {
event: payload.type,
workspace_id: payload.workspace_id,
page_id: page.id || null,
parent_database_id: page.parent && page.parent.database_id
? page.parent.database_id
: null,
updated_properties: payload.data ? payload.data.updated_properties || [] : [],
occurred_at: payload.timestamp
};
}Slack Notification for New Pages
function transform(payload) {
const page = payload.data && payload.data.page ? payload.data.page : {};
const titleProp = page.properties && page.properties.Name
? page.properties.Name.title
: null;
const title = titleProp && titleProp.length
? titleProp[0].text.content
: page.id;
return {
text: `New Notion page: ${title}`,
blocks: [
{
type: "section",
fields: [
{ type: "mrkdwn", text: `*Page:*\n${title}` },
{ type: "mrkdwn", text: `*Event:*\n${payload.type}` }
]
}
]
};
}Filter Examples
Filter conditions read dotted paths out of the payload. logic must be AND or OR.
Page Creations Only
{
"name": "New Pages",
"logic": "AND",
"conditions": [
{
"field": "type",
"operator": "equals",
"value": "page.created"
}
]
}One Database
{
"name": "Specs Database",
"logic": "AND",
"conditions": [
{
"field": "data.page.parent.database_id",
"operator": "equals",
"value": "db-ghi789"
}
]
}Any Page Change
{
"name": "Page Changes",
"logic": "AND",
"conditions": [
{
"field": "type",
"operator": "starts_with",
"value": "page."
}
]
}Headers
| Header | Description |
|---|---|
X-Notion-Signature | Hex HMAC-SHA256 of the raw body, prefixed sha256= |
Hookbase does not forward these headers to your destination. A delivery is a fresh request:
Hookbase sets Content-Type, User-Agent: Hookbase/1.0, X-Delivery-ID and X-Event-ID, then
adds the headers and auth you configured on the destination. Nothing Notion sent reaches your
handler as a header — read what you need out of the body, or set it on the destination yourself.
Of the incoming headers, only content-type, user-agent, x-github-event, x-gitlab-event and
stripe-signature are stored on the event, so those are the only ones visible later in the
dashboard or the API.
Troubleshooting
Signature Verification Failed
- Confirm the source's
providerisnotion— a source left oncustomreadsX-Signature,X-Webhook-SignatureandX-Hub-Signature-256, and Notion sendsX-Notion-Signature - Confirm
signingSecretis the subscription'sverification_token(the value Notion POSTed to your ingest URL when you created the subscription), not the connection's API token - If you recreate the subscription, Notion sends a new
verification_token. Open the source, and use the new token as the signing secret once Notion has verified it
No Verification Token Appears
- Confirm the source's
providerisnotion. Other providers store the request as an ordinary event - Tokens are held for 24 hours. After that, click Resend token in Notion's verification dialog
- Notion sends the token only while the subscription is unverified; an already-verified subscription sends events, not tokens
Missing Events
- The connection must be shared with the page or database. Content the connection cannot see produces no events
- Check which event types the subscription includes on the connection's Webhooks tab
- A page inside an unshared parent inherits that: sharing the parent is usually the fix
Duplicate Events
When deduplication is enabled, Hookbase's auto strategy
keys on the event's top-level id, so a redelivery of the same event is caught. Editing the same
page twice produces two events with different ids; that is Notion reporting two changes, not a
duplicate.