Appearance
Webhooks
So that you never have to ask whether something is done: Protoki calls an address you own as soon as the minutes and tasks of a meeting exist. Choose where to register the address based on whose meetings it should receive:
- Personal webhook: create it under Settings → Integrations. It receives only meetings you can also see in the app. You do not need admin access or the workspace's personal API-key feature to be enabled. The installation must include the personal webhook database migration; if it is missing, the settings page explains that the feature is not yet available.
- Workspace webhook: a workspace admin creates it under Admin → Webhooks. It receives events for the workspace, rather than being limited to one person's visible meetings.
The signing secret is shown when you create the endpoint or explicitly rotate its secret. Store the new secret in your receiver; it cannot be displayed again later.
There are two events. The field tables, the headers, the retry schedule and the rules for answering are on their own pages:
meeting.ready— the minutes are readymeeting.failed— processing broke off, there are no minutes
A newly registered endpoint defaults to meeting.ready only. Enable meeting.failed explicitly in the endpoint's settings if you also want failure events, so that an existing automation does not suddenly receive calls without minutes.
What a body looks like
The meeting.ready body carries the key facts of the meeting, the participants, the short version and the tasks — deliberately without the text of the minutes and without the transcript. Both are one call away, under links. That keeps the body small and keeps what was said out of other people's log files.
json
{
"event": "meeting.ready",
"delivery_id": "8f14e45f-ceea-467a-9e2b-1d3f5c0a7b21",
"occurred_at": "2026-08-08T10:12:03.441Z",
"workspace_id": "…",
"data": {
"meeting": { "id": "…", "title": "Quarterly talk", "platform": "teams",
"starts_at": "2026-08-08T09:00:00Z", "ends_at": "2026-08-08T10:00:00Z" },
"protocol": { "id": "…", "tldr": "Quote by Friday, engineering clarifies the interface.",
"generated_at": "2026-08-08T10:11:58Z" },
"action_items": [ { "id": "…", "task": "Send the quote", "owner_name": "Anna",
"due": "2026-08-15", "verdict": "open", "status": "open" } ],
"participants": [ { "display_name": "Anna", "email": "anna@company.example",
"is_internal": true, "attended": true } ]
},
"links": {
"meeting": "https://protoki.com/api/v1/meetings/…",
"transcript": "https://protoki.com/api/v1/meetings/…/transcript",
"action_items": "https://protoki.com/api/v1/meetings/…/action-items",
"app": "https://protoki.com/meetings/…"
}
}A meeting.failed body carries the meeting and a failure block with the stage it broke off at.
json
{
"event": "meeting.failed",
"delivery_id": "3b1f7c02-5d64-4c1a-9f88-2ac0e6b41d77",
"occurred_at": "2026-08-08T10:12:03.441Z",
"workspace_id": "…",
"data": {
"meeting": { "id": "…", "title": "Quarterly talk", "platform": "teams",
"status": "failed" },
"failure": { "stage": "transcribe", "retryable": true }
},
"links": {
"meeting": "https://protoki.com/api/v1/meetings/…",
"app": "https://protoki.com/meetings/…"
}
}Trying it out
The admin area has a Send test call button. It posts a signed call with "event": "ping" to that one endpoint straight away and shows you the response — no retries, no real meeting data. So you do not have to wait for a meeting to find out whether your end is up.
Failed deliveries are listed with a reason in the admin area. Look there instead of guessing.
Verifying the signature
Every call carries Protoki-Signature: t=…,v1=…. What is signed is the timestamp and the body, not the body alone — otherwise a call captured once could be replayed at will. So check the age as well.
js
import { createHmac, timingSafeEqual } from 'node:crypto';
// IMPORTANT: the RAW body. If you run it through JSON.parse and back through
// JSON.stringify, key order and whitespace change — and the check then fails
// every time.
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=').map((s) => s.trim())),
);
const t = Number(parts.t);
if (!Number.isInteger(t)) return false;
// 1. Check the age — this is the replay protection.
if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false;
// 2. HMAC over "<t>.<body>".
const expected = createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
// 3. Compare in constant time.
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1 ?? '', 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}