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.
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
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>
| Scope | Grants |
|---|---|
meetings:read | List and read meetings, minutes, action items |
meetings:write | Create, reprocess and delete meetings |
actions:write | Update action item status, owner, due date |
recap:generate | Billed. Generate the AI recap image |
webhooks:manage | Register and remove webhook subscriptions |
Grant the narrowest set that works. A dashboard that only displays results needs
meetings:read and nothing else.
One endpoint, three doors. Send whichever field you have — cheapest first.
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 } }
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"}'
Requires a meeting bot provider to be configured on the server.
-d '{"title": "Weekly Sync", "meeting_url": "https://teams.microsoft.com/l/meetup-join/..."}'
curl -X POST https://your-host/api/v1/meetings/upload \
-H "Authorization: Bearer $KEY" \
-F "audio=@meeting.m4a" -F "title=Weekly Sync"
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 } }
Poll the meeting, or better, subscribe to a webhook.
Status moves queued → transcribing → summarizing →
done (or failed, with error explaining why).
| Method | Path | Returns |
|---|---|---|
GET | /api/v1/me | Organization, scopes, queue depth |
GET | /api/v1/meetings | List; filter by status, since, limit |
GET | /api/v1/meetings/:id | Structured minutes + action items |
GET | /api/v1/meetings/:id?transcript=true | Same, including the full transcript |
GET | /api/v1/meetings/:id/mom.md | Minutes as Markdown |
GET | /api/v1/meetings/:id/card | Adaptive Card; ?wrap=teams for Teams |
GET | /api/v1/meetings/:id/recap.png | Stored recap image (never generates one) |
GET | /api/v1/meetings/:id/audio | Original recording; ?download=1 names the file |
GET | /api/v1/recap-styles | Card styles this organization can use |
POST | /api/v1/meetings/:id/reprocess | {"mom_only": true} reuses the transcript |
DELETE | /api/v1/meetings/:id | Removes 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.
curl -X POST https://your-host/api/v1/meetings/$ID/recap-image \
-H "Authorization: Bearer $KEY"
"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.
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_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.
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.
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.
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.
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.
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.
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-..."
}
}
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();
}
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.
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.
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.
Every error has the same shape: {"error": "message in plain language"}.
| Status | Meaning |
|---|---|
400 | Malformed request — the message says which field |
401 | Missing, unknown or revoked key |
403 | Key lacks the required scope |
404 | Not found, or belongs to another organization |
409 | Already being processed |
413 | Body or file too large |
429 | Daily quota for a billed operation is exhausted |
502 | An 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.