Guide to Granola Webhooks: Features and Best Practices
Granola webhooks tell your systems when a meeting note is ready, when its summary changes, and when someone shares a note with you. If your team takes meeting notes in Granola, the AI notepad, webhooks are how those notes reach your CRM, knowledge base or internal tools without polling the Granola API on a timer.
This guide covers how Granola webhook endpoints are created and scoped, the three events and their payloads, how deliveries are signed, retried and disabled, and the best practices for running a Granola receiver in production.
What are Granola webhooks?
A Granola webhook is an HTTPS endpoint you register in the Granola app or through the Granola API. When a subscribed event happens to a note your endpoint can see, Granola sends it a JSON POST. Webhooks are available on the Business and Enterprise plans.
The payloads are deliberately small. Each one names the event and the note, and nothing else: no summary, no transcript, no attendees. When you receive one, you fetch the note with your Granola API key. Granola applies its access checks at fetch time, so a webhook never exposes more than the API would.
Granola signs every delivery natively with the Standard Webhooks specification. Each request carries webhook-id, webhook-timestamp and webhook-signature headers, and each endpoint has its own whsec_ signing secret.
Granola webhook features
| Feature | Details |
|---|---|
| Availability | Business and Enterprise plans |
| Configuration | Settings > Connectors > Webhooks, a folder's Integrations > Webhooks, or the API (POST /v1/webhook-endpoints) |
| Scoping | personal, public or workspace scopes, plus an optional folder filter that includes subfolders |
| Events | note.generated, note.edited, note.access_granted. Omitting events subscribes to all three |
| Payload | event_id, event_type, note_id, occurred_at. No note content |
| Signature headers | webhook-id, webhook-timestamp, webhook-signature |
| Scheme | Standard Webhooks: HMAC-SHA256, base64, entries prefixed with v1, |
| Signed content | {webhook-id}.{webhook-timestamp}.{raw body} |
| Secret | One whsec_ secret per endpoint, shown only once when the endpoint is created |
| Replay window | Granola recommends rejecting timestamps "more than a few minutes old", without giving a number |
| Response window | 15 seconds |
| Retries | 408, 429, 5xx, network errors and timeouts, with exponential backoff for four days |
| Permanent failures | 3xx (redirects are not followed) and any other 4xx |
| Auto-disable | After four days with no successful delivery. Missed events are not replayed |
| Endpoint URL | Public HTTPS only. Addresses on private networks are rejected |
Common events
Granola has three event types, one for each way a note can change for you.
| Event | Sent when |
|---|---|
note.generated | The first AI summary for a note is generated while your endpoint can access the note |
note.edited | The note's summary is edited or regenerated |
note.access_granted | A note is shared with you, directly or through a folder |
The difference between the first and last matters. note.generated fires once, when the first summary is created, and only if your endpoint can see the note at that moment. If a note that already has a summary is shared with you later, you get note.access_granted instead. To discover every note that becomes visible to you, subscribe to both.
Here is Granola's example payload:
{
"event_id": "8f1c2a4e-6b3d-4e8f-9a2b-1c5d7e9f0a3b",
"event_type": "note.generated",
"note_id": "not_1d3tmYTlCICgjy",
"occurred_at": "2026-01-27T15:30:00Z"
}
event_ididentifies the event. Retries of the same delivery reuse it, and it matches thewebhook-idheader.event_typeis one of the three events above. Route on it.note_idis the note to fetch with Get Note.occurred_atis when the event happened, in ISO 8601.
note.edited payloads also carry a data object whose changed_fields lists the fields that changed. Today that is always ["summary"], but reading the array rather than assuming it leaves room for Granola to add more.
Setting up Granola webhooks
You can create an endpoint in the app or through the API. Either way you also need a Granola API key to fetch the notes your endpoint is told about.
In the Granola app
- Go to Settings > Connectors > Webhooks and select Set up a webhook.
- Choose which notes and events to receive, then enter your endpoint URL.
- Create the webhook and copy its signing secret straight away. Store it server-side, for example as
GRANOLA_WEBHOOK_SECRET. - From the confirmation dialog, send a test event, and create a compatible API key if you don't have one.
For a webhook that covers one folder, open the folder, select Integrations, then Webhooks and Create new webhook. Granola filters it to notes in that folder and its subfolders.
Through the API
curl -X POST "https://public-api.granola.ai/v1/webhook-endpoints" \
-H "Authorization: Bearer grn_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/granola-webhooks",
"scopes": ["personal", "public"]
}'
The response includes signing_secret. It is returned only in this response and can't be retrieved later. The scopes decide which notes the endpoint hears about:
personalcovers notes you own, notes shared directly with you, and notes in private folders shared with you.publiccovers notes visible to everyone in the workspace.workspaceis for workspace API keys, which must pass exactly["workspace"]. The endpoint then belongs to the workspace, and its scope can't be changed later.
Add events to subscribe to a subset of events, and folder_ids to limit delivery to notes in those folders and their subfolders. Make the scopes match the folders: personal for a private folder, public for a workspace-visible one. On Enterprise plans, workspace admins decide which scopes members may use.
You can list, update and delete endpoints through the same API. An update can change the URL, scopes, events and folder filter, or set enabled to false to pause deliveries.
If you use Hookdeck, create a source with the Granola source type, paste the whsec_ secret into it, and register the Hookdeck source URL as the endpoint URL in Granola.
Securing Granola webhooks
To verify a delivery, join webhook-id, webhook-timestamp and the raw request body with dots. Take the part of the secret after whsec_, base64-decode it, and use the bytes as the HMAC-SHA256 key. Encode the result as base64 and compare it in constant time with each v1, entry in the space-delimited webhook-signature header. A match on any entry is enough.
Granola also recommends rejecting deliveries whose webhook-timestamp is more than a few minutes old. The check is optional in the code below and off by default. If you turn it on, choose the window yourself, and remember that a delivery replayed later, for example from Hookdeck, keeps its original timestamp.
const crypto = require('crypto');
const express = require('express');
function verifyGranolaSignature(rawBody, headers, secret, toleranceSeconds = 0) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatureHeader = headers['webhook-signature'];
if (!id || !timestamp || !signatureHeader || !secret || !secret.startsWith('whsec_')) return false;
if (!/^\d+$/.test(timestamp)) return false;
// Optional replay check, off by default.
if (toleranceSeconds > 0 && Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
return false;
}
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected = crypto
.createHmac('sha256', key)
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest();
return signatureHeader.split(' ').some((entry) => {
const [version, signature] = entry.split(',');
if (version !== 'v1' || !signature) return false;
const received = Buffer.from(signature, 'base64');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
const app = express();
app.post('/webhooks/granola', express.raw({ type: '*/*' }), (req, res) => {
if (!verifyGranolaSignature(req.body, req.headers, process.env.GRANOLA_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicate on event.event_id, queue a job to fetch event.note_id, then return.
res.sendStatus(200);
});
The same check in Python:
import base64
import binascii
import hashlib
import hmac
import os
import time
def verify_granola_signature(raw_body: bytes, headers, secret: str, tolerance_seconds: int = 0) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signature_header = headers.get("webhook-signature")
if not (msg_id and timestamp and signature_header and secret and secret.startswith("whsec_")):
return False
if not timestamp.isdigit():
return False
# Optional replay check, off by default.
if tolerance_seconds > 0 and abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
try:
key = base64.b64decode(secret[len("whsec_"):])
except (binascii.Error, ValueError):
return False
signed = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
for entry in signature_header.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature.encode("utf-8"), expected):
return True
return False
# In FastAPI: raw_body = await request.body()
# verify_granola_signature(raw_body, request.headers, os.environ["GRANOLA_WEBHOOK_SECRET"])
Because Granola follows the specification, the open-source standardwebhooks libraries also work. They always enforce a five-minute timestamp window, so they reject a delivery replayed after that, even though its signature is valid.
To check your verifier, use the secret whsec_, the ID 8f1c2a4e-6b3d-4e8f-9a2b-1c5d7e9f0a3b, the timestamp 1769527800 and this exact body:
{"event_id":"8f1c2a4e-6b3d-4e8f-9a2b-1c5d7e9f0a3b","event_type":"note.generated","note_id":"not_1d3tmYTlCICgjy","occurred_at":"2026-01-27T15:30:00Z"}
The webhook-signature header should be:
v1,5zbQNEBUXLsg8fqYfjkVjYsbDZLgSxuhhACW6wowYkU=
Make Granola webhooks production-ready. Hookdeck Event Gateway verifies the Standard Webhooks signature on every Granola delivery, answers well inside the 15-second window, and holds each note event until your service has fetched and processed the note.
Granola webhook limitations and pain points
A wrong status code loses the event
The Problem: Events go missing after a deploy, a routing change or a verification bug, and Granola never retries them.
Why It Happens: Granola only retries 408, 429, 5xx, network errors and timeouts. A 3xx or any other 4xx, including a 401 from a misconfigured verifier or a 404 from a moved route, fails the delivery permanently on the first attempt.
Workarounds:
- Return
5xxrather than4xxfor failures you expect to be temporary, such as a database outage. - Test verification with the vector above before you point Granola at a new receiver.
- Register the final HTTPS URL exactly as your server serves it, so no redirect is involved.
How Hookdeck Can Help: Hookdeck verifies the signature and returns a 2xx to Granola as soon as the event is safely stored. If your service answers with a 4xx or 404, the event stays in Hookdeck, where you can fix the problem and retry or replay it.
A disabled endpoint drops events for good
The Problem: After an outage, notes created during the outage never arrive.
Why It Happens: Granola retries for four days. If nothing succeeds in that time, it disables the endpoint and emails its creator or the workspace admins, and events missed while it is disabled are not replayed. Pausing an endpoint with enabled: false has the same effect: events that happen while paused are never delivered.
Workarounds:
- Make sure the endpoint creator or admins act on Granola's disable email.
- After re-enabling, reconcile by listing recent notes through the API and fetching any you haven't processed.
- Don't pause an endpoint to hold events back. Pausing discards them.
How Hookdeck Can Help: Hookdeck keeps accepting Granola's deliveries while your service is down, so the endpoint never reaches the four-day disable threshold. Events wait in Hookdeck until your service is back.
Every event costs an API call
The Problem: Bursts of meetings produce 429 Too Many Requests errors when your receiver fetches the notes.
Why It Happens: The payload carries only a note_id, so each event means a Get Note call, often with the transcript as well. The Granola API allows a burst of 25 requests in 5 seconds and 5 requests per second sustained, per user or workspace depending on the key.
Workarounds:
- Acknowledge the webhook first, then fetch notes from a queue whose concurrency keeps you under 5 requests per second.
- Back off and retry on
429. - For long meetings, expect Get Note to return
TRANSCRIPT_TOO_LARGEwhen you ask for the transcript inline, and page through/v1/notes/{note_instead.id}/transcript
How Hookdeck Can Help: Hookdeck can deliver to your service at a rate you set, so a burst of webhooks becomes a steady stream of fetches that stays inside Granola's API limits.
The signing secret is shown once
The Problem: You lose the secret, or need to change it, and can't get it back.
Why It Happens: Granola returns signing_secret only when the endpoint is created. It isn't included in list responses, and the documented update fields don't include rotating it.
Workarounds:
- Store the secret in a secrets manager the moment you create the endpoint.
- To change it, create a new endpoint with the same settings, deploy its secret, then delete the old endpoint. Deduplicate on
event_idduring the overlap.
How Hookdeck Can Help: If the Granola endpoint points at Hookdeck, only the Hookdeck source holds the Granola secret. Your own service authenticates Hookdeck instead, so a Granola secret change touches one place.
Missing notes when relying on note.generated
The Problem: Notes shared with you after their summary was generated never show up.
Why It Happens: note.generated is sent only if your endpoint can access the note when its first summary is generated. Access granted later produces note.access_granted, which you may not have subscribed to.
Workarounds:
- Subscribe to both
note.generatedandnote.access_granted, and treat them the same way: fetch the note and upsert it. - Check that your scopes and folder filter match where the notes actually live.
How Hookdeck Can Help: Hookdeck's event log shows which event types are arriving for your source, which makes a missing subscription easy to spot.
Best practices
Treat payloads as pointers
Store the note_id and fetch the note in a background job, rather than calling the API inside the request. The fetched note is the source of truth, and fetching it later also picks up any edits made in between.
Upsert by note, not by event
note.generated, note.edited and note.access_granted can all point at the same note. Key your stored copy on note_id and overwrite it on each fetch, so the order the events arrive in doesn't matter.
Deduplicate on event_id
Retries reuse event_id, and it equals the webhook-id header. Record it atomically before processing, for example with a unique index or Redis SET NX. See our guide to webhook idempotency.
Acknowledge within 15 seconds
Verify, persist, return 200, and do the API calls afterwards. See why to process webhooks asynchronously.
Read changed_fields
For note.edited, look at data.changed_fields instead of assuming the summary changed. It is always ["summary"] today, and reading it keeps your code correct if Granola adds fields.
Conclusion
Granola's webhooks are notifications rather than data: each signed POST names a note and an event, and your service fetches the note through the API. Verify the Standard Webhooks signature against the raw body, subscribe to both note.generated and note.access_granted, and answer within 15 seconds with a status code Granola will retry if something is wrong. A 4xx or a four-day outage loses events for good.
Hookdeck Event Gateway sits in front of your receiver, verifies every Granola signature, and keeps each event until your service has processed it, so a bad deploy or an outage on your side doesn't cost you meeting notes.
Get started with Hookdeck for free and handle Granola webhooks reliably in minutes.