jcolombo / granola-api-php
PHP SDK for the Granola meeting-notes API, with a framework-agnostic webhook parser and signature verifier
Requires
- php: >=8.1
- ext-json: *
- adbario/php-dot-notation: ^3.3
- guzzlehttp/guzzle: ^7.8
Requires (Dev)
- phpunit/phpunit: ^10.5
README
A PHP SDK for the Granola meeting-notes API, with a framework-agnostic webhook parser and signature verifier.
Overview
An independently developed PHP toolkit for reading Granola meeting notes, transcripts and folders, managing webhook endpoints, and turning inbound webhook deliveries into typed PHP objects. Not affiliated with or endorsed by Granola.
Granola API documentation: https://docs.granola.ai/introduction
Stability notice: this package is in active development (v0.1.x). The API surface may change before v1.0. Pin to
^0.1in production.
What Granola's API can and cannot do
Worth knowing before you design around it:
- It is read-only, apart from webhook endpoints. You can read notes, transcripts, folders and audit events. There is no endpoint that creates or edits a note.
- It only returns notes that already have a generated AI summary and transcript. A note visible in the desktop app may not be listable yet.
- There is no "get one folder" endpoint. Folders are listed, then indexed locally.
- Webhook payloads name a note; they do not carry it. Content requires a follow-up API call.
- Personal and workspace API keys need a Business or Enterprise plan.
Features
- Every documented endpoint — list/get notes, paged transcripts, folders, audit events, and full CRUD on webhook endpoints
- Cursor pagination done properly —
fetch()for one page,fetchAll()for all of them,each()to stream a large archive at constant memory, and a cursor you can persist between cron runs - A webhook toolkit, not a framework — Standard Webhooks HMAC verification and typed parsing; your application keeps its own endpoint, routing and queueing
- Lazy note hydration — a webhook event fetches its note only if a handler actually asks for one
- Multiple API keys — one configured default plus any number of named overrides, side by side
- Typed objects throughout —
DateTimeImmutabletimestamps, enums for speakers, scopes and event types, and value objects for users and calendar events - Forward-compatible — unknown response fields are kept, not dropped; unknown webhook event types parse instead of throwing
- Transparent 413 handling — a transcript too large to inline falls back to the paged endpoint on its own
- Rate limiting — a client-side dual-window limiter matched to Granola's published budget, with
Retry-After-aware 429 retries - Optional caching and logging — off by default, file-backed or delegated to your own stack
- No framework dependencies — Guzzle and a dot-notation config helper, nothing else
Requirements
- PHP 8.1 or higher
- A Granola account on a Business or Enterprise plan, with API access
- A Granola API key (
grn_…) - Composer
Installation
composer require jcolombo/granola-api-php
Getting an API key
In the Granola desktop app: Settings → Connectors → API keys → Create new key, then choose the scopes it should carry.
| Scope | What it reaches |
|---|---|
| Personal | Notes you own, notes shared directly with you, and private folders shared with you |
| Public | Notes visible to everyone in the workspace, including team space notes |
| Workspace | Admin-created keys only: public workspace notes plus spaces with Granola API access enabled. These keys do not expire. |
Keys are revoked from the same screen, and revocation is permanent.
Quick start
use Jcolombo\GranolaApiPhp\Configuration; use Jcolombo\GranolaApiPhp\Granola; use Jcolombo\GranolaApiPhp\Entity\Resource\Note; Configuration::set('connection.apiKey', getenv('GRANOLA_API_KEY')); $granola = Granola::connect(); foreach (Note::list()->pageSize(30)->fetch() as $note) { echo $note->title, ' — ', $note->created_at->format('Y-m-d'), PHP_EOL; }
Every resource resolves the default connection on its own, so Note::list() needs no arguments once connect() has been called.
Connecting
The default key
Put the key in configuration and connect with no arguments:
Configuration::set('connection.apiKey', getenv('GRANOLA_API_KEY')); // or load it from a file: Configuration::overload(__DIR__ . '/config'); $granola = Granola::connect();
Additional keys
Extra keys live alongside the default and are addressed by name:
$workspace = Granola::connect($workspaceKey, 'workspace'); // Anywhere later, without passing the key around: $folders = Folder::all('workspace'); $notes = Note::list('workspace')->fetch(); // Or fetch the connection itself: $same = Granola::connection('workspace');
Connections are keyed by API key, so connecting twice with the same key returns the same instance. The first connection made becomes the default; Granola::setDefault('workspace') moves it.
Reading notes
One note
$note = Note::find('not_1d3tmYTlCICgjy'); echo $note->title; echo $note->summary(); // markdown when present, else plain text echo $note->summary_markdown; echo $note->web_url; $note->created_at; // DateTimeImmutable $note->owner()->email; // User value object $note->attendeeEmails(); // ['oat@granola.ai', ...] $note->folderIds(); // ['fol_4y6LduVdwSKC27'] $note->isInFolder('fol_4y6LduVdwSKC27');
Listing and filtering
$notes = Note::list() ->updatedAfter('2026-08-01') // string or DateTimeInterface ->createdBefore(new DateTimeImmutable('now')) ->inFolder('fol_4y6LduVdwSKC27') // includes child folders ->pageSize(30) // Granola caps this at 30 ->fetch();
A listing returns the summary shape only — id, object, title, owner, created_at, updated_at. Summaries, attendees, calendar event and transcript arrive with Note::find(), so walking a list is cheap and you pay for detail only on the notes you actually want.
The calendar event
$event = $note->calendarEvent(); // null for ad-hoc recordings $event?->eventTitle; $event?->scheduledStartTime; // DateTimeImmutable $event?->scheduledMinutes(); // 60 $event?->inviteeEmails(); $event?->isExternal(); // true when an invitee is outside the organiser's domain
Pagination
Granola pages with an opaque cursor and a hasMore flag. It also warns that a page can hold fewer items than you asked for and still not be the last one — so never page by counting results. This SDK always drives paging from hasMore.
$notes = Note::list(); $notes->fetch(); // one page $notes->fetchNext(); // append the next page $notes->fetchAll(); // every page, all resident in memory foreach ($notes->each() as $note) { // every page, one page resident // ... }
fetchAll() is the "give me the complete set, stitched together" call — no manual paging loop. It is safe to call at any point: it resumes from wherever the collection stopped, returns immediately when everything is already loaded, and never re-requests a page it holds. To deliberately re-query with different filters, reset first with rewindPages().
each() is the streaming equivalent, for sets large enough that holding them all is the wrong shape. It yields anything already loaded before paging on, so it too can be called on a partly-fetched collection without skipping or repeating items.
Both are bounded by maxPages (1000 by default, ->maxPages(50) to change, ->maxPages(null) to remove). If the bound truncates a walk, hasMore() stays true and cursor() is non-null — so truncation is detectable and resumable, never silent.
Resuming later
The cursor is public state, so a long-running sync can stop after any page and pick up where it left off:
$page = Note::list()->updatedAfter($lastSync)->fetch(); $cursor = $page->cursor(); $store->save('granola_cursor', $cursor); // ... the next cron run ... Note::list()->withCursor($store->get('granola_cursor'))->fetch();
Collection access
count($notes); // items loaded, not the server-side total $notes->first(); $notes->last(); $notes->find('not_1d3tmYTlCICgjy'); // by id $notes['not_1d3tmYTlCICgjy']; // same, via ArrayAccess $notes->flatten('title'); // one property from every item $notes->toArray(); json_encode($notes);
Transcripts
$transcript = $note->transcript(); foreach ($transcript->each() as $item) { echo $item->toLine(), PHP_EOL; // "Alice Smith: we should ship on Friday" $item->text(); $item->speaker()?->label(); $item->speaker()?->isMe(); $item->startTime(); // DateTimeImmutable $item->durationSeconds(); } echo $transcript->toText(); // whole thing as text $transcript->fromMe(); // only the note owner's lines $transcript->speakerLabels();
Inline vs paged, and the 413
Asking for the transcript with the note is one request instead of two:
$note = Note::find('not_1d3tmYTlCICgjy', withTranscript: true); $note->hasInlineTranscript(); // true $note->transcript(); // no extra request — already loaded
For long meetings Granola answers 413 TRANSCRIPT_TOO_LARGE instead. The SDK handles it: the note is re-fetched without the transcript, transcriptWasTooLarge() becomes true, and transcript() pages from /v1/notes/{id}/transcript instead.
You never have to branch on which happened. Both of these do the right thing either way:
// Complete transcript, stitched together and held in memory. $text = Note::find($id, true)->transcript()->fetchAll()->toText(); // Same coverage, streamed one page at a time. foreach (Note::find($id, true)->transcript()->each() as $item) { ... }
When the transcript arrived inline, both return what is already loaded and make no further request. When it did not, they page until the transcript is complete.
Set notes.autoFallbackLargeTranscript to false to get a TranscriptTooLargeException and handle it yourself.
Speaker attribution
speaker.source is the only guaranteed field.
- macOS transcripts carry
attribution—me(the note's owner) orthem. - iOS captures a single audio stream, so
sourceis alwaysmicrophone,attributionmay be absent, and an anonymousdiarization_label(Speaker A) may appear instead. nameappears only when Granola resolved the speaker to a person.
$speaker->label() picks the best available: name, then diarization label, then attribution, then source.
Folders
Granola has no "get one folder" endpoint, and returns the hierarchy flat as a parent_folder_id on each folder. Fetch them all once and index locally:
$folders = Folder::all(); $folders->find('fol_4y6LduVdwSKC27')->name(); $folders->roots(); $folders->childrenOf('fol_a74g2hvl98iUHG'); $folders->descendantsOf('fol_a74g2hvl98iUHG'); // any depth $folders->pathOf('fol_9m2QpRsTuVwX10'); // "Product / Top secret recipes / Greek" $folders->tree(); // nested ['folder' => Folder, 'children' => [...]]
Folder IDs are what restrict a webhook endpoint's deliveries and filter Note::list(), and both include child folders automatically.
Audit events
Requires a workspace API key on an Enterprise plan.
foreach (AuditEvent::list()->action('workspace')->occurredAfter('2026-08-01')->each() as $event) { echo $event->action(), ' by ', $event->actorLabel(), PHP_EOL; }
actionis an open set of dot-separated strings Granola adds to over time, so it is not an enum here.isAction('workspace')matchesworkspace.member_addedbut notworkspace_automation.created— prefix matching stops at the dot.- Events are ordered by
collected_at, notoccurred_at. Granola learns about some events after the fact, socollected_atis the field that never moves under a cursor. actoris one of four shapes —user,api_key,system,anonymous.actorLabel()renders any of them;actorEmail(),actorUserId()andactorApiKeySuffix()reach the specifics.- Both date filters must fall inside Granola's one-year retention window.
Webhooks
Two separate jobs, covered separately: registering an endpoint with Granola, and receiving what it delivers.
Registering an endpoint
use Jcolombo\GranolaApiPhp\Entity\Resource\WebhookEndpoint; use Jcolombo\GranolaApiPhp\Enum\WebhookScope; use Jcolombo\GranolaApiPhp\Enum\WebhookEventType; $endpoint = WebhookEndpoint::register( url: 'https://example.com/granola-webhooks', scopes: [WebhookScope::Personal, WebhookScope::Public], events: [WebhookEventType::NoteGenerated, WebhookEventType::NoteEdited], // omit for all folderIds: ['fol_4y6LduVdwSKC27'], // omit for everything ); $secret = $endpoint->signingSecret(); // ⚠️ shown exactly once, right here
Store the signing secret immediately. Granola returns it in the create response and never again. Lose it and the endpoint has to be recreated.
Managing endpoints:
$endpoints = WebhookEndpoint::all(); $endpoints->enabled(); $endpoints->paused(); $endpoints->subscribedTo(WebhookEventType::NoteGenerated); $endpoint->disable(); // pause deliveries $endpoint->enable(); $endpoint->restrictToFolders(['fol_…'])->save(); // PATCHes only what changed $endpoint->delete();
A paused endpoint keeps its configuration and secret, but events that occur while it is paused are not delivered later.
Endpoints you did not create come back with url reduced to its origin (url_redacted === true), because a webhook path can carry credentials.
Receiving a delivery
This package does not run your endpoint and does not dispatch to listeners — routing, queueing, retries and deduplication stay in your application. What it removes is the part every integration would otherwise reimplement: getting the signature check exactly right, and parsing the payload into something typed.
use Jcolombo\GranolaApiPhp\Webhook\Webhook; use Jcolombo\GranolaApiPhp\Webhook\WebhookHeaders; $rawBody = file_get_contents('php://input'); // the RAW bytes — see below try { $event = Webhook::parse($rawBody, WebhookHeaders::fromGlobals(), $secret, Granola::connect()); } catch (SignatureVerificationException $e) { http_response_code(400); exit; } $event->eventId; // unique per delivery — your deduplication key $event->type; // WebhookEventType enum, or null for a type we don't know $event->noteId; $event->occurredAt; // DateTimeImmutable $event->changedFields; // ['summary'] on note.edited $event->payload; // the original array, untouched $note = $event->note(); // one GET, memoised, only if you ask $note = $event->note(true); // with the transcript $event->transcript();
Then hand it to your own code:
match (true) { $event->isGenerated() => $queue->push(new IndexNote($event->noteId)), $event->isEdited() => $queue->push(new RefreshSummary($event->noteId)), $event->isAccessGranted() => $queue->push(new GrantAccess($event->noteId)), default => null, // an event type this SDK version predates }; http_response_code(200);
WebhookHeaders accepts whatever your stack produces — fromGlobals(), fromArray(getallheaders()), fromArray($request->getHeaders()) for PSR-7/Laravel/Symfony, or fromAny($whatever).
Five things that will bite you
- Verify the raw body. The signature covers the exact bytes Granola sent. A framework that hands you a decoded array has already destroyed the key order and whitespace, and re-encoding it will never match. In Laravel that means
$request->getContent(), not$request->all(). - Answer within 15 seconds. Granola retries on timeouts and 5xx with exponential backoff for four days, then disables the endpoint and emails workspace admins. Queue the work; respond immediately.
- Deduplicate on
event_id. Every retry of a delivery carries the same one.Webhook::deliveryId($headers)reads it before the body is even parsed. - Expect events you don't recognise. Granola adds them. An unknown type parses with
type === nullandrawTypeset, rather than throwing — do notmatchon it without adefault. - The payload has no note content. It names a note; fetching it is a second call against your rate limit.
Which responses count as what:
| Your response | Granola's behaviour |
|---|---|
| 2xx | Delivered |
| 408, 429, 5xx, timeout, network error | Retried with backoff for four days |
| 3xx (redirects are not followed), other 4xx | Failed, not retried |
Signature scheme
Standard Webhooks, verified against three headers — webhook-id, webhook-timestamp, webhook-signature (v1,<base64>):
signature = base64(HMAC-SHA256("{webhook-id}.{webhook-timestamp}.{raw body}", key))
key = base64_decode(signing secret without its "whsec_" prefix)
The verifier compares in constant time, accepts several space-separated signatures (so a secret rotation does not drop deliveries), and rejects a timestamp more than webhook.toleranceSeconds (300 by default) away from now in either direction.
Webhook::parseUnverified() exists for replaying stored deliveries and for tests. Never point it at live traffic.
Configuration
Defaults ship in default.granolaapi.config.json. Layer your own on top — each load deep-merges, so an override only needs the keys it changes.
Configuration::overload(__DIR__ . '/config'); // finds granolaapi.config.json Configuration::load('/etc/myapp/granola.json'); // or an explicit file Configuration::set('connection.apiKey', $key); // or set values directly
The keys you are most likely to touch:
| Key | Default | What it does |
|---|---|---|
connection.apiKey |
null |
The default API key |
connection.timeout |
30 |
HTTP timeout, seconds |
webhook.signingSecret |
null |
Default secret for Webhook::parse() |
webhook.toleranceSeconds |
300 |
Accepted delivery-timestamp drift |
error.throwOnApiError |
false |
Throw ApiException on a non-2xx instead of logging |
notes.autoFallbackLargeTranscript |
true |
Recover from a 413 automatically |
enabled.cache |
false |
Cache GET responses |
enabled.logging |
false |
Write a request log |
devMode |
false |
Warn about unexpected enum values |
Full reference: docs/CONFIGURATION.md.
Error handling
By default a non-2xx is logged, not thrown — matching the sibling SDKs in this family. The call returns an unpopulated object you can interrogate:
$note = Note::find('not_doesNotExist'); if ($note->succeeded() !== true) { $note->lastResponse()->responseCode; // 404 $note->lastResponse()->errorMessage(); $note->lastResponse()->errorCode(); // Granola's machine-readable code }
Most applications will prefer exceptions:
Configuration::set('error.throwOnApiError', true); try { $note = Note::find($id); } catch (ApiException $e) { $e->isUnauthorized(); // 401 — bad or revoked key $e->isForbidden(); // 403 — scope disabled by workspace API access controls $e->isNotFound(); // 404 $e->isRateLimited(); // 429 }
Every exception extends GranolaException.
Rate limits
Granola allows a 25-request burst over any 5 seconds and 5 requests/second sustained (300/minute). The SDK tracks both windows locally and waits rather than earning a 429. If one arrives anyway, it honours Retry-After when present and otherwise backs off exponentially, up to rateLimit.maxRetries.
The limiter is per API key and per PHP process — it does not coordinate across workers. If you run parallel jobs against one key, lower rateLimit.perMinute proportionally.
See docs/CACHING-AND-RATE-LIMITS.md.
Documentation
| Document | Contents |
|---|---|
| docs/API-REFERENCE.md | Every class, method and property |
| docs/WEBHOOKS.md | Full webhook guide: registration, receiving, verification, operations |
| docs/CONFIGURATION.md | Every configuration key |
| docs/CACHING-AND-RATE-LIMITS.md | Caching, custom backends, limiter behaviour |
| docs/INTEGRATION.md | Recipes for plain PHP, Laravel, Symfony, Slim; sync patterns |
| examples/ | Eight runnable scripts |
| OVERRIDES.md | Design decisions, and why each was made |
| CONTRIBUTING.md | Development setup and testing |
Testing
composer test # PHPUnit — fixtures, no key or network needed composer test:live # live checks against the real API
The PHPUnit suite runs against fixtures derived from Granola's published OpenAPI document, so a change on their side surfaces as a failing test. The live runner needs no test framework and checks that those documented shapes are still what Granola actually sends.
License
MIT — see LICENSE.
This is an independent project. Granola is a trademark of its respective owner; this package is not affiliated with or endorsed by Granola.