@odla-ai/granola
v0.1.1
Published
Pull Granola meeting notes into odla-db and the odla CRM: a typed client for Granola's public API, Standard Webhooks verification, an idempotent mirror of notes/transcripts/folders, and a CRM projection that turns attendees into records and each note into
Maintainers
Readme
@odla-ai/granola
Pull Granola meeting notes into odla-db and the odla CRM.
A typed client over Granola's public API, Standard Webhooks verification, an idempotent mirror of notes, transcripts and folders, and a CRM projection that turns attendees into records and each note into a meeting activity.
No runtime dependency beyond @odla-ai/crm, which only the ./crm entry
reaches for — the database and the CRM are both injected structurally.
What it does
Two triggers feed one ingest path:
| Trigger | For | Entry point |
|---|---|---|
| Webhook | Live updates | createGranolaRoutes() |
| Incremental poll | Backfill and reconciliation | syncNotes() |
Both land in ingestNoteById(), which mirrors the note into odla-db and — when
a CRM port is wired — projects it onto CRM records. Everything is idempotent: a
redelivered webhook and a poll that sweeps the same window twice converge on
identical rows.
Setup
You need a Granola API key (Settings → Connectors → API keys in the desktop app; Business plan or above). Store it as a vault secret, never a wrangler var — it reads every note its scopes cover.
odla-ai secrets set granola_api_key --env dev --stdinInstall the schema and its deny-all rules:
import { GRANOLA_SCHEMA, GRANOLA_RULES } from "@odla-ai/granola";
// POST GRANOLA_SCHEMA to /app/:id/schema, GRANOLA_RULES to /app/:id/admin/rulesThe namespaces are deny-all by design. They hold meeting transcripts, which are the most sensitive rows most apps will store, so no end-user credential reads them — access goes through your worker.
Mirroring notes
import { initGranola, syncNotes } from "@odla-ai/granola";
const client = initGranola({ apiKey: env.GRANOLA_API_KEY });
const result = await syncNotes({ db }, client);
// { ingested: 12, failed: [], lastUpdatedAt: 1769000000000, truncated: false }syncNotes walks updated_after from a stored watermark. A note that fails to
ingest holds the watermark behind itself, so the next run retries it rather than
reporting success over a note it dropped.
Read it back:
import { getStoredNote, getStoredTranscript, listNotes } from "@odla-ai/granola";
const recent = await listNotes({ db }, { updatedAfter: Date.now() - 7 * 864e5 });
const transcript = await getStoredTranscript({ db }, recent[0].noteId);Projecting onto the CRM
import { ingestNoteById } from "@odla-ai/granola";
import { crmPort } from "@odla-ai/granola/crm";
const port = crmPort({ crm, db });
await ingestNoteById({ db }, client, noteId, {
port,
mapping: { personType: "person", companyType: "company" },
});Every attendee becomes a person record and every email domain a company; the
note lands once per person as a kind: "meeting" activity carrying the summary
and a link back to Granola. Records resolve through crm_record_origin under
the granola source id, so the same attendee maps to the same record on every
later note.
Mapping options
| Option | Default | Notes |
|---|---|---|
| personType | "person" | Record type for an attendee |
| companyType | "company" | null skips company records entirely |
| companyRelation | unset | Opt-in. A CRM rejects a relation its config does not declare, so there is deliberately no default |
| activityKind | "meeting" | One of crm's ACTIVITY_KINDS |
| ignoredCompanyDomains | [] | Pass CONSUMER_EMAIL_DOMAINS to keep gmail.com and friends from becoming companies |
| ignoredEmails | [] | Skip an attendee entirely — your own team, a notetaker bot |
Receiving webhooks
Register the endpoint and store the signing secret immediately — it is returned exactly once and can never be read back:
const endpoint = await client.createWebhookEndpoint({
url: "https://your-app.dev/api/granola/webhook",
scopes: ["personal", "public"],
});
// endpoint.signing_secret → granola_webhook_secretMount the receiver in your worker:
import { createGranolaRoutes } from "@odla-ai/granola";
const granolaRoutes = createGranolaRoutes({
deps: { db },
client,
signingSecret: env.GRANOLA_WEBHOOK_SECRET,
port,
});
export default {
async fetch(request, env, ctx) {
return (await granolaRoutes(request)) ?? handleEverythingElse(request);
},
};Deliveries are verified per the Standard Webhooks
spec: HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{body}, compared in
constant time, with a timestamp tolerance so a captured body cannot be replayed
indefinitely. A delivery that fails verification is refused with 400 and never
ingested. One that verifies but fails to ingest gets 500 — and because the event
is only recorded after a successful ingest, Granola's retry does the work
rather than being skipped as a duplicate.
Granola's delivery timeout is not a good place to page a long transcript. To hand the work to a queue instead:
createGranolaRoutes({ deps: { db }, client, signingSecret, ingest: false,
onEvent: (event) => env.QUEUE.send(event) });Subscribe to both note.generated and note.access_granted: an
already-generated note that is shared with you later arrives as the latter.
Notes on the upstream API
- Only notes with a generated AI summary and transcript are returned. Notes still processing do not appear in List Notes and 404 from Get Note.
- Rate limits are a 25-request burst per 5s window, sustained at 5/s. The client
retries 429s and 5xx with backoff that honours
Retry-After. - A transcript too large to inline answers 413
TRANSCRIPT_TOO_LARGE;fetchNoteWithTranscriptrecovers by paging it separately. created_byon a workspace-managed webhook endpoint is genuinelynull.
License
MIT
