‹ Back to app Documentation ›

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.

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.

Authentication Scopes Creating meetings Reading results Search & ask 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

Personal access tokens

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.

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"

Choosing a minutes template

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.

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 queued → transcribing → summarizing → done (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/mom.docxMinutes as a Word document — the same file as Download → Word in the app
GET/api/v1/meetings/:id/mom.pdfMinutes as a PDF — the same file as Download → PDF in the app
GET/api/v1/meetings/:id/transcript.txtTranscript as plain text; .srt and .vtt give timed captions when the transcript has timestamps
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
GET/api/v1/templatesMinutes 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/:idMoves 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.

Recap image

curl -X POST https://your-host/api/v1/meetings/$ID/recap-image \
  -H "Authorization: Bearer $KEY"
This one is metered: each image counts against the plan’s monthly recap cards and takes about 30 seconds. Results are cached: calling it again returns "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.

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.

MethodPathReturns
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
Asking is a paid operation. Each question is one call to the language model and counts against the plan's monthly questions. A question across meetings that finds nothing to read is answered without calling the model, and is not counted. Both need the meetings:read scope and are rate-limited per key.

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.

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.

Slack

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.

MCP server

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.

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.