‹ Back to app

API Guide

Notulen AI turns meeting recordings and transcripts into structured minutes (MOM) and action items. Everything the web app does is also available to other systems over a versioned HTTP API.

Authentication Scopes Creating meetings Reading results Action items Limits Status Webhooks MCP server Errors & limits

Authentication

Machine clients authenticate with an API key sent as a bearer token. Keys are created on the server by an administrator and shown once — only a hash is stored.

# run on the server, once per integration
node src/admin.js key:add <org-slug> "n8n production" \
  --scopes "meetings:read meetings:write" \
  --quota '{"recap:generate":10}'

Send the key on every request:

Authorization: Bearer nk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Verify a key and see what it can do:

curl -H "Authorization: Bearer $KEY" https://your-host/api/v1/me
One instance, many organizations. Every key belongs to exactly one organization and can only ever see that organization's meetings. There is no cross-organization access, and no key can widen its own reach.

A leaked key is revoked individually — other integrations keep working:

node src/admin.js key:list <org-slug>
node src/admin.js key:revoke <org-slug> <key-id>

Scopes

ScopeGrants
meetings:readList and read meetings, minutes, action items
meetings:writeCreate, reprocess and delete meetings
actions:writeUpdate action item status, owner, due date
recap:generateBilled. Generate the AI recap image
webhooks:manageRegister and remove webhook subscriptions

Grant the narrowest set that works. A dashboard that only displays results needs meetings:read and nothing else.

Creating meetings

One endpoint, three doors. Send whichever field you have — cheapest first.

1. You already have a transcript

No speech-to-text is performed, so this costs nothing beyond the summarisation itself. Use it for Teams live captions, Zoom transcripts, old VTT files, or notes typed by hand.

curl -X POST https://your-host/api/v1/meetings \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: weekly-sync-2026-09-05" \
  -d '{
    "title": "Weekly Sync",
    "transcript": "Budi: Good morning everyone...",
    "participants": "Budi, Rina, Andi",
    "agenda": "Release status, budget"
  }'

Meeting platforms export WebVTT, not plain text. Send the file contents as-is — VTT and SRT are detected and normalised into speaker-labelled lines, consecutive cues from the same speaker are merged, and Teams <v Name> tags become speaker labels. The response tells you what was recognised:

{ "id": "...", "status": "queued",
  "billable": { "transcription": false },
  "transcript": { "format": "vtt", "segments": 6 } }

2. You have a recording somewhere

The server downloads it, so you never upload hundreds of megabytes yourself. HTTPS only; private and internal addresses are rejected.

  -d '{"title": "Weekly Sync", "audio_url": "https://storage.example.com/rec.m4a"}'

3. The meeting is live and a bot should join

Requires a meeting bot provider to be configured on the server.

  -d '{"title": "Weekly Sync", "meeting_url": "https://teams.microsoft.com/l/meetup-join/..."}'

Or upload a file directly

curl -X POST https://your-host/api/v1/meetings/upload \
  -H "Authorization: Bearer $KEY" \
  -F "audio=@meeting.m4a" -F "title=Weekly Sync"
Always send Idempotency-Key from automated callers. Without it, a retrying workflow creates duplicate meetings and pays for transcription twice. Repeating a request with the same key returns the original meeting instead of creating a new one.

All four return immediately — processing happens in the background:

{ "id": "8522b5d8-...", "status": "queued", "queue_position": 2,
  "billable": { "transcription": false } }

Reading results

Poll the meeting, or better, subscribe to a webhook. Status moves queuedtranscribingsummarizingdone (or failed, with error explaining why).

MethodPathReturns
GET/api/v1/meOrganization, scopes, queue depth
GET/api/v1/meetingsList; filter by status, since, limit
GET/api/v1/meetings/:idStructured minutes + action items
GET/api/v1/meetings/:id?transcript=trueSame, including the full transcript
GET/api/v1/meetings/:id/mom.mdMinutes as Markdown
GET/api/v1/meetings/:id/cardAdaptive Card; ?wrap=teams for Teams
GET/api/v1/meetings/:id/recap.pngStored recap image (never generates one)
GET/api/v1/meetings/:id/audioOriginal recording; ?download=1 names the file
GET/api/v1/recap-stylesCard styles this organization can use
POST/api/v1/meetings/:id/reprocess{"mom_only": true} reuses the transcript
DELETE/api/v1/meetings/:idRemoves meeting, audio and action items

The structured minutes follow a fixed shape, so you can map fields without guessing:

{
  "id": "...", "title": "Weekly Sync", "status": "done",
  "mom": {
    "title": "...", "date": "...", "time": "...", "location": "...",
    "executive_summary": "...",
    "attendees":  [{ "name": "...", "role": "..." }],
    "agenda":     ["..."],
    "discussion": [{ "topic": "...", "points": ["..."] }],
    "decisions":  [{ "decision": "...", "rationale": "...", "owner": "..." }],
    "action_items": [{ "task": "...", "owner": "...", "due": "before Wednesday",
                       "due_date": "2026-09-10", "priority": "high", "status": "open" }],
    "risks": [{ "issue": "...", "impact": "...", "mitigation": "..." }],
    "open_questions": ["..."],
    "next_meeting": { "date": "...", "agenda": ["..."] }
  },
  "action_items": [ ... ]
}

mom.action_items is what the model extracted. The top-level action_items are the tracked rows people can update — use those.

Recap image

curl -X POST https://your-host/api/v1/meetings/$ID/recap-image \
  -H "Authorization: Bearer $KEY"
This one costs money (roughly USD 0.07 per image) and takes about 30 seconds. Results are cached: calling it again returns "cached": true at no cost unless you pass {"force": true}. Give any key that holds recap:generate a daily quota. Text inside an AI-generated image is never guaranteed to be verbatim — check names, dates and figures before sharing.

The card's visual style is picked at random from the styles this organization has enabled, and the response says which one was used. Pass style to pin one — list the slugs with GET /api/v1/recap-styles first; an unknown or disabled slug is rejected with 400 before anything is billed.

curl -X POST https://your-host/api/v1/meetings/$ID/recap-image \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"force": true, "style": "majalah-editorial"}'

{ "cached": false, "at": "...", "model": "vertex:gemini-3-pro-image",
  "bytes": 1483920, "style": "majalah-editorial", "styleName": "Editorial majalah" }

Regenerating without a style deliberately picks a style other than the one just used, so paying twice does not return the same-looking card. Styles themselves are managed in the app under Templates → Recap card styles, not through this API.

Original recording

curl -L -H "Authorization: Bearer $KEY" \
  -o meeting.wav \
  "https://your-host/api/v1/meetings/$ID/audio?download=1"

Streams the file the meeting was transcribed from. ?download=1 adds a Content-Disposition filename built from the meeting title and date; without it the bytes are served inline, which is what the in-app player uses.

Range requests are supported, so a multi-hundred-megabyte recording can be fetched in parts and resumed after a dropped connection. The meeting JSON tells you whether it is still worth asking: has_audio, audio_bytes and audio_mime.

Audio expires before the rest does. AUDIO_RETENTION_DAYS (30 by default) deletes recordings while the transcript, minutes and action items stay. After that this endpoint answers 404 with a reason — so an archiving integration should pull the audio well inside that window, not months later.

Ready-made Teams card

GET /api/v1/meetings/$ID/card?wrap=teams

Returns an Adaptive Card — title, meeting figures, decisions, and action items with owner and deadline — already wrapped as a message attachment, which is exactly what the Power Automate action Post card in a chat or channel accepts. Drop ?wrap=teams to get the bare card for other renderers. Requires meetings:read.

Action items

GET   /api/v1/action-items?owner=Rina&status=open&overdue=true
PATCH /api/v1/action-items/:id   {"status": "done"}

Action items survive regeneration: if the minutes are rebuilt and the task text still matches, the status a person set is kept.

Rate limits

Daily quotas cap how often a paid operation runs. They do not cap the rate — a misconfigured flow can send thousands of requests a minute without breaching any quota.

X-RateLimit-Limit:     120
X-RateLimit-Remaining: 118

The default is 120 requests per minute per API key. Over it you get 429 with a Retry-After header in seconds — wait that long rather than retrying immediately. Sign-in is limited separately, and counts only failed attempts.

Service status

One call that answers “is anything broken?” — intended for a monitor, not for a person reading a dashboard.

curl https://your-host/api/v1/status -H "Authorization: Bearer $KEY"
{
  "health": "sehat",              // sehat | ada_kegagalan | perlu_diperiksa
  "queue": { "pending": 0, "running": false },
  "meetings_24h": { "done": 12, "failed": 1 },
  "meetings_stuck": 0,            // unfinished for over an hour
  "recent_failures": [ { "id": "...", "title": "...", "error": "..." } ],
  "webhooks_24h": { "delivered": 12, "failed": 0 },
  "troubled_deliveries": [ ],     // gave up, or currently retrying
  "keys": [ { "name": "...", "used_today": { "recap:generate": 3 }, "quota": {...} } ]
}

health is the one field worth alerting on. perlu_diperiksa means work is stuck or a webhook has been abandoned; ada_kegagalan means individual meetings failed but nothing is stuck. Requires the meetings:read scope.

Why this exists. When people upload meetings themselves, a failure has a witness — someone is watching the screen. Once an automated flow submits them, nothing fails loudly any more, and the only way to notice was to read the server log.

Webhooks

Register a URL and stop polling.

curl -X POST https://your-host/api/v1/webhooks \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"url": "https://your-app.example.com/hooks/notulen",
       "events": ["meeting.completed", "meeting.failed"]}'

The response contains a secret and a header_token, both shown once. Events: meeting.completed, meeting.failed, recap.ready (or "*" for all).

Add "include_card": true to have the ready-made Teams card travel inside the meeting.completed payload, under data.card. This exists for consumers that cannot call back: in Power Automate the HTTP action is a premium connector, so a flow that fetches the card needs a paid plan, while a flow that reads it from the payload runs on standard Microsoft 365 licensing.

It is a trade, and it is off by default. The payload normally carries identifiers and a link so meeting content is never pushed to an endpoint that may no longer belong to you. With include_card the summary, decisions and action items travel with it. Turn it on only for endpoints you control.
{
  "event": "meeting.completed",
  "at": "2026-09-05T04:15:22.930Z",
  "data": {
    "id": "8522b5d8-...", "title": "Weekly Sync", "source": "api",
    "action_items": 2, "decisions": 3,
    "url": "https://your-host/api/v1/meetings/8522b5d8-..."
  }
}
The payload deliberately excludes the minutes themselves. It carries identifiers and a link; whoever needs the content fetches it with their own key. Meeting content is therefore never pushed to an endpoint that may no longer belong to you.

Verifying a delivery

X-Notulen-Event:     meeting.completed
X-Notulen-Delivery:  <delivery id>
X-Notulen-Timestamp: 1788533657
X-Notulen-Signature: sha256=<hex>
X-Notulen-Token:     <header_token>

The signature is HMAC-SHA256(secret, "<timestamp>.<raw body>"). Compare it against the raw body, before any JSON parsing:

const expected = 'sha256=' + crypto
  .createHmac('sha256', secret)
  .update(`${req.get('X-Notulen-Timestamp')}.${rawBody}`)
  .digest('hex');

if (!crypto.timingSafeEqual(Buffer.from(expected),
                            Buffer.from(req.get('X-Notulen-Signature')))) {
  return res.status(401).end();
}

If you cannot compute an HMAC

Some low-code consumers — Power Automate, Zapier — have no HMAC function. Every delivery therefore also carries X-Notulen-Token, a fixed value you compare for equality. It is weaker than the signature, since it is bound to neither the body nor the time, but it is what makes a flow verifiable at all.

Never use a URL taken from the payload as a request target. A webhook body is input from outside your system. If your flow fetches data.url directly and attaches your API key, one forged delivery is enough to hand that key to someone else's server. Build the URL from a host you wrote down yourself, and use only the id from the payload.

Failed deliveries are retried with increasing delays (30 seconds up to 2 hours, six attempts) and every attempt is recorded — ask your administrator for node src/admin.js webhook:list <org-slug> if a delivery never arrived. Reply 2xx quickly; slow endpoints are treated as failures after 10 seconds.

MCP server

An MCP server ships with the application, so AI assistants can query meetings and create minutes directly. It has no dependencies and talks to this same API.

{
  "mcpServers": {
    "notulen": {
      "command": "node",
      "args": ["/opt/notulen-ai/src/mcp/server.js"],
      "env": {
        "NOTULEN_BASE_URL": "https://your-host",
        "NOTULEN_API_KEY": "nk_..."
      }
    }
  }
}

Tools: list_meetings, get_meeting, get_mom_markdown, create_mom_from_transcript, list_action_items, update_action_item.

Errors & limits

Every error has the same shape: {"error": "message in plain language"}.

StatusMeaning
400Malformed request — the message says which field
401Missing, unknown or revoked key
403Key lacks the required scope
404Not found, or belongs to another organization
409Already being processed
413Body or file too large
429Daily quota for a billed operation is exhausted
502An upstream provider (speech, model) refused the request

Other limits worth designing around: uploads are capped by server configuration (commonly 500 MB), JSON bodies on /api/v1 accept long transcripts (20 MB by default), and processing runs one meeting at a time — a long recording delays everything queued behind it, which is why responses include queue_position.

Version 1. Paths under /api/v1 are a stable contract; the routes the web app uses are internal and change without notice.