Claude-code-plugins-plus granola-webhooks-events

install
source · Clone the upstream repo
git clone https://github.com/jeremylongshore/claude-code-plugins-plus-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/jeremylongshore/claude-code-plugins-plus-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/saas-packs/granola-pack/skills/granola-webhooks-events" ~/.claude/skills/jeremylongshore-claude-code-plugins-plus-granola-webhooks-events && rm -rf "$T"
manifest: plugins/saas-packs/granola-pack/skills/granola-webhooks-events/SKILL.md
source content

Granola Webhooks & Events

Overview

Granola does not expose raw webhook endpoints. All event-driven automation flows through Zapier, which provides two trigger events. This skill covers the event model, webhook payload structure, event filtering, processing patterns, and building custom event handlers.

Prerequisites

  • Granola Business plan (for Zapier access)
  • Zapier account (Free for basic Zaps, Paid for multi-step)
  • Optional: custom webhook endpoint (Express.js, FastAPI, or serverless function)

Instructions

Step 1 — Understand the Event Model

Granola fires events through Zapier triggers, not direct webhooks. Two triggers are available:

TriggerWhen It FiresUse Case
Note Added to Granola FolderA note is placed in a specific folder (automatic)Auto-route by meeting type
Note Shared to ZapierYou manually click Share > Zapier on a noteSelective sharing for important meetings

Step 2 — Webhook Payload Structure

When a Zapier trigger fires, Granola sends this data:

{
  "title": "Sprint Planning — Q1 Week 12",
  "creator_name": "Sarah Chen",
  "creator_email": "sarah@company.com",
  "attendees": [
    {"name": "Sarah Chen", "email": "sarah@company.com"},
    {"name": "Mike Johnson", "email": "mike@company.com"},
    {"name": "Alex Kim", "email": "alex@external.com"}
  ],
  "calendar_event_title": "Sprint Planning",
  "calendar_event_datetime": "2026-03-22T10:00:00Z",
  "note_content": "## Summary\nDiscussed Q1 priorities...\n\n## Action Items\n- [ ] @sarah: Schedule design review..."
}

Key fields for filtering and routing:

  • attendees[].email
    — detect internal vs. external meetings
  • calendar_event_title
    — match meeting type patterns
  • note_content
    — search for action items, decisions, keywords

Step 3 — Event Filtering Patterns

Use Zapier Filter steps to route events:

Filter: Only External Meetings

Filter: attendees.email DOES NOT contain "@company.com"
(at least one attendee has a non-company email)

Filter: Only Meetings with Action Items

Filter: note_content contains "- [ ]"

Filter: Only Sales Calls (by title keywords)

Filter: calendar_event_title contains any of: "discovery", "demo", "sales", "prospect"

Filter: Long Meetings Only (> 30 min)

Use Zapier Code step to parse calendar_event_datetime and compare to note timestamp

Step 4 — Build a Custom Webhook Handler

Forward Granola events from Zapier to your own endpoint:

# Zapier configuration
Trigger: Granola — Note Added to Folder ("All Meetings")
Action: Webhooks by Zapier — POST
  URL: https://your-api.com/webhooks/granola
  Payload Type: JSON
  Data:
    title: "{{title}}"
    creator: "{{creator_email}}"
    attendees: "{{attendees}}"
    content: "{{note_content}}"
    datetime: "{{calendar_event_datetime}}"
    hmac: "{{your_webhook_secret}}"

Express.js handler:

// webhook-handler.js
import express from 'express';
const app = express();
app.use(express.json());

app.post('/webhooks/granola', async (req, res) => {
  const { title, creator, attendees, content, datetime } = req.body;

  // Validate webhook (use HMAC or shared secret)
  // if (!verifyHmac(req)) return res.status(401).send('Unauthorized');

  console.log(`Meeting received: ${title} (${datetime})`);

  // Extract action items
  const actionItems = content
    .split('\n')
    .filter(line => line.match(/^- \[ \]/))
    .map(line => line.replace('- [ ] ', ''));

  // Route based on meeting type
  const isExternal = attendees.some(a => !a.email?.endsWith('@company.com'));

  if (isExternal) {
    await handleExternalMeeting({ title, attendees, content, actionItems });
  } else {
    await handleInternalMeeting({ title, content, actionItems });
  }

  res.status(200).json({ processed: true, actions: actionItems.length });
});

async function handleExternalMeeting({ title, attendees, content, actionItems }) {
  // CRM update, follow-up email draft, Slack #sales notification
  console.log(`External meeting: ${title}, ${actionItems.length} action items`);
}

async function handleInternalMeeting({ title, content, actionItems }) {
  // Linear tasks, Notion archive, Slack #team notification
  console.log(`Internal meeting: ${title}, ${actionItems.length} action items`);
}

app.listen(3000, () => console.log('Granola webhook handler running on :3000'));

Python FastAPI handler:

from fastapi import FastAPI, Request
import re

app = FastAPI()

@app.post("/webhooks/granola")
async def handle_granola_event(request: Request):
    data = await request.json()
    title = data.get("title", "Untitled")
    content = data.get("content", "")
    attendees = data.get("attendees", [])

    # Extract action items
    actions = re.findall(r"- \[ \] (.+)", content)

    # Route by attendee type
    external = [a for a in attendees if not a.get("email", "").endswith("@company.com")]

    if external:
        # Process external meeting
        await process_external(title, actions, external)
    else:
        await process_internal(title, actions)

    return {"processed": True, "action_count": len(actions)}

Step 5 — Processing Patterns

PatternWhen to UseImplementation
ImmediateTime-sensitive follow-upsDirect Zapier actions, ~2 min latency
BatchReduce noise, aggregateQueue to SQS/Redis, process every 15 min
ConditionalRoute by meeting typeZapier Paths or custom webhook with routing logic
IdempotentPrevent duplicate processingStore processed note IDs, skip duplicates

Step 6 — Error Handling and Retry

Zapier handles retries automatically for failed actions. For custom webhooks:

// Implement idempotency
const processedNotes = new Set(); // Use Redis/DB in production

app.post('/webhooks/granola', async (req, res) => {
  const noteId = `${req.body.title}-${req.body.datetime}`;

  if (processedNotes.has(noteId)) {
    return res.status(200).json({ status: 'already_processed' });
  }

  processedNotes.add(noteId);
  // ... process the event
});

Output

  • Zapier triggers configured for target folders
  • Event filtering routing meetings by type
  • Custom webhook handler processing events
  • Idempotency preventing duplicate processing

Error Handling

ErrorCauseFix
Trigger not firingWrong folder name in ZapierVerify folder name matches exactly (case-sensitive)
Empty note_contentNote still processing when trigger firesAdd 2-minute Delay step before processing actions
Duplicate eventsZapier retry on timeoutImplement idempotency with note ID deduplication
Webhook timeoutHandler takes > 30sReturn 200 immediately, process async
Missing attendeesCalendar event has no attendee listNo fix — attendees come from calendar event data

Resources

Next Steps

Proceed to

granola-performance-tuning
for transcription quality optimization.