Gareth Wilson Gareth Wilson

Guide to Granola Webhooks: Features and Best Practices

Published


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

FeatureDetails
AvailabilityBusiness and Enterprise plans
ConfigurationSettings > Connectors > Webhooks, a folder's Integrations > Webhooks, or the API (POST /v1/webhook-endpoints)
Scopingpersonal, public or workspace scopes, plus an optional folder filter that includes subfolders
Eventsnote.generated, note.edited, note.access_granted. Omitting events subscribes to all three
Payloadevent_id, event_type, note_id, occurred_at. No note content
Signature headerswebhook-id, webhook-timestamp, webhook-signature
SchemeStandard Webhooks: HMAC-SHA256, base64, entries prefixed with v1,
Signed content{webhook-id}.{webhook-timestamp}.{raw body}
SecretOne whsec_ secret per endpoint, shown only once when the endpoint is created
Replay windowGranola recommends rejecting timestamps "more than a few minutes old", without giving a number
Response window15 seconds
Retries408, 429, 5xx, network errors and timeouts, with exponential backoff for four days
Permanent failures3xx (redirects are not followed) and any other 4xx
Auto-disableAfter four days with no successful delivery. Missed events are not replayed
Endpoint URLPublic 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.

EventSent when
note.generatedThe first AI summary for a note is generated while your endpoint can access the note
note.editedThe note's summary is edited or regenerated
note.access_grantedA 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_id identifies the event. Retries of the same delivery reuse it, and it matches the webhook-id header.
  • event_type is one of the three events above. Route on it.
  • note_id is the note to fetch with Get Note.
  • occurred_at is 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

  1. Go to Settings > Connectors > Webhooks and select Set up a webhook.
  2. Choose which notes and events to receive, then enter your endpoint URL.
  3. Create the webhook and copy its signing secret straight away. Store it server-side, for example as GRANOLA_WEBHOOK_SECRET.
  4. 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:

  • personal covers notes you own, notes shared directly with you, and notes in private folders shared with you.
  • public covers notes visible to everyone in the workspace.
  • workspace is 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_Z3Jhbm9sYS1ndWlkZS10ZXN0LWtleS0zMi1ieXRlcyE=, 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 5xx rather than 4xx for 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_LARGE when you ask for the transcript inline, and page through /v1/notes/{note_id}/transcript instead.

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_id during 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.generated and note.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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.