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.
A machine-readable description of every /api/v1 path lives at
/openapi.json (OpenAPI 3.1) — import it into Postman,
or generate a client from it.
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
Anyone can also create a token of their own under Account › Personal access
tokens. It is sent the same way, and it acts as that person: it sees the
meetings they can open — their own and the ones shared with them or their teams — and it
stops working the moment their access is removed. An organisation key, by contrast, sees
every meeting in the organisation. GET /api/v1/me tells the two apart:
key.personal is true and user names the person.
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"
Every door above — and /upload — accepts an optional template_id.
List the choices with GET /api/v1/templates. Leave it out (or send an empty
string) to use whichever template is the organisation default when the meeting is
processed. An unknown id is rejected with 400 before anything is created,
downloaded, or sent — it is never silently replaced with the default.
-d '{"title": "Board meeting", "audio_url": "https://...", "template_id": "3f9c..."}'
The meeting then reports both sides: template_id is what was chosen
(null = the default), mom_template is the name of the template that
actually wrote the latest minutes.
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/mom.docx | Minutes as a Word document — the same file as Download → Word in the app |
GET | /api/v1/meetings/:id/mom.pdf | Minutes as a PDF — the same file as Download → PDF in the app |
GET | /api/v1/meetings/:id/transcript.txt | Transcript as plain text; .srt and .vtt give timed captions when the transcript has timestamps |
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 |
GET | /api/v1/templates | Minutes templates, with their instructions and which one is the default |
POST | /api/v1/meetings/:id/reprocess | {"mom_only": true} reuses the transcript; add "template_id" to rewrite with another template ("" = back to the default) |
DELETE | /api/v1/meetings/:id | Moves the meeting to Trash, where it can be restored from the app until purge_at. ?permanent=true removes the meeting, audio and action items at once |
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 without using another card 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.
Without style, the card is drawn in the style chosen for that meeting
(on its page in the app, or when it was recorded, uploaded, or the bot was sent), and
otherwise in the organisation's default card style (Settings → Recap cards).
The response says which one was used. Pass style to pin one for this call —
list the slugs with GET /api/v1/recap-styles first — or
"random" for a random style; 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 redraws in the meeting's chosen (or default)
style with a fresh colour variation. When that choice is random — or you pass
"random" — a style other than the one just used is picked, 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.
| Method | Path | Returns |
|---|---|---|
GET | /api/v1/search?q=… | Full-text search over titles, minutes and transcripts — the same search as the app. limit up to 50; each result carries a snippet with the match marked ‹like this› |
POST | /api/v1/meetings/:id/ask | {"question": "…"} — an answer from that meeting's transcript, with the quotes it rests on |
POST | /api/v1/ask | {"question": "…", "days": 90, "tag": "q4"} — one question across every meeting; searched lists the meetings that were read |
meetings:read scope and are rate-limited per key.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.
Administrators can send a test from Integrations › Webhooks. It arrives as
event ping, signed like any other delivery, with data.webhook_id and no
meeting. It is sent once and not retried; answer it with 2xx like the rest.
A Slack channel does not need a receiver of your own. An administrator pastes the channel's
Incoming Webhook URL under Integrations › Slack channel, and Notulen AI posts a
message — title, summary, the count of decisions and action items, and a link — instead of the
signed JSON payload. The URL is itself the credential, so once saved it is never shown in full
again, here or in GET /api/v1/webhooks.
An MCP server ships with the application, so AI assistants can search and ask across meetings, read minutes and transcripts, and create minutes directly. It has no dependencies and talks to this same API, so scopes, quotas and limits apply unchanged.
Remote (HTTP). Nothing to install on the assistant's machine — point the client
at /mcp on this server with an API key:
{
"mcpServers": {
"notulen": {
"type": "http",
"url": "https://your-host/mcp",
"headers": { "Authorization": "Bearer nk_..." }
}
}
}
Local (stdio). For clients that only start local servers, copy
src/mcp/ to that machine:
{
"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, search_meetings, get_meeting,
get_mom_markdown, get_transcript, ask_meeting,
ask_meetings, create_mom_from_transcript, list_templates,
list_action_items, update_action_item. The two ask tools are
paid per question, exactly like the endpoints above. The ready-to-paste configuration for both
transports is under Integrations › AI assistants (MCP) in the app.
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.