# 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:

```json
{
  "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](https://docs.granola.ai/api-reference/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](https://docs.granola.ai/help-center/sharing/integrations/granola-api) 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

```bash
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.

```javascript
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:

```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](/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](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge within 15 seconds

Verify, persist, return `200`, and do the API calls afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Granola webhooks reliably in minutes.