Skip to content

Commit be75c66

Browse files
rrrutledgeclaude
andauthored
Zoom Recaps to Slack: verified webhook trigger and docs (#1)
* feat(nodes) verify Zoom webhook and post recaps from it Zoom Recaps to Slack now answers Zoom's endpoint validation, checks x-zm-signature on every call, replies within Zoom's 3 second limit, and waits 20 minutes for the summary to settle before it posts. The daily sweep is unchanged and remains the safety net. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CL8UVjy1Feax5ShfQSsopm * docs(nodes) describe the Zoom Recaps to Slack workflow Covers the config, posted and webhook-secret Data tables, the webhook and daily sweep, the Zoom Event Subscription setup, and how to load a change into n8n. Drops the outdated starter-plan note from update-workflow.sh. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CL8UVjy1Feax5ShfQSsopm * chore(nodes) keep the Webhook node diff minimal and annotate the new nodes Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CL8UVjy1Feax5ShfQSsopm --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 18967b4 commit be75c66

2 files changed

Lines changed: 321 additions & 2 deletions

File tree

‎nodes/ZOOM_RECAPS_TO_SLACK.md‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
## How It Works
2+
3+
The "Zoom Recaps to Slack" workflow posts each finished Zoom meeting's AI Companion recap and action items into the Slack channel for that meeting series.
4+
Each action item's assignee becomes a real Slack `@`-mention, and a name that can't be matched stays plain text.
5+
Recaps post to the team channel automatically.
6+
7+
The workflow has two ways in, and both feed one shared posting path:
8+
9+
1. **Webhook**: Zoom calls the workflow when a meeting's summary finishes, and the recap posts about 20 minutes later, so the summary has finished settling.
10+
2. **Daily sweep**: a Schedule Trigger runs each morning, asks Zoom for the previous day's meeting summaries, and posts anything the webhook missed.
11+
Zoom's summary webhook sometimes doesn't fire, and the sweep covers those misses.
12+
13+
Both paths post only meetings listed in the Config table below.
14+
15+
### Data tables
16+
17+
Three n8n Data tables hold the workflow's state.
18+
Their contents live in n8n.
19+
20+
| Table | Purpose |
21+
| --- | --- |
22+
| Config | One row per recurring Zoom meeting ID, giving its Slack channel and a `chathamHouse` checkbox. A meeting whose ID isn't in the table is ignored. |
23+
| Posted | The meeting instances already posted, keyed by each occurrence's unique ID, so a recap posts exactly once however many times the workflow runs over it. |
24+
| Zoom Recaps - Webhook Secret | One row, column `secretToken`: the secret token of the Zoom app's Event Subscription. |
25+
26+
Adding a meeting series is adding a Config row; the workflow itself doesn't change.
27+
Checking `chathamHouse` on a row posts that series under the Chatham House Rule: a filter pass removes every name and affiliation, next steps are grouped under "Person 1", "Person 2" and so on, and nobody is `@`-mentioned.
28+
29+
### Webhook checks
30+
31+
Every call to the webhook is checked before anything else runs:
32+
33+
- **Validation challenge answered:** Zoom's endpoint URL validation gets the HMAC of its challenge token, which proves the workflow holds the subscription's secret token.
34+
- **Signature required:** every other call must carry a valid `x-zm-signature` (the HMAC of the request timestamp and raw body).
35+
A call without one gets a 401 and stops.
36+
- **200 reply first:** Zoom gets its reply within its three-second limit, before the wait and the posting begin.
37+
- **Only configured summaries proceed:** only a "summary completed" event for a meeting in the Config table goes on to the posting path.
38+
39+
The secret token sits in a Data table because this n8n plan has no Variables feature and Code nodes can't read credentials.
40+
41+
## Zoom setup
42+
43+
The webhook comes from an Event Subscription on the "ISC Zoom Recaps to Slack" Server-to-Server OAuth app in the Zoom Marketplace.
44+
45+
1. Open the app's **Feature** tab and turn on **Event Subscriptions**.
46+
2. Add a subscription with the endpoint URL set to the workflow's **production** webhook URL (the Webhook node's Production URL in n8n), and subscribe to **Meeting summary has been completed**.
47+
3. Copy the subscription's **Secret Token** into the `secretToken` cell of the "Zoom Recaps - Webhook Secret" Data table.
48+
4. Click **Validate** in Zoom.
49+
The workflow answers the validation challenge using the stored token.
50+
51+
When the secret token is regenerated in Zoom, paste the new value into the same cell.
52+
53+
## Credentials
54+
55+
The workflow uses the Slack bot credential, a header-auth credential for the Claude filter calls, and a basic-auth credential for Zoom's Server-to-Server token.
56+
The JSON carries the credential ids of the ISC instance, which a different instance won't have; map them to its own credentials as described in [USAGE.md](../USAGE.md).
57+
58+
## Loading a change into n8n
59+
60+
The workflow's n8n id is `pLIk4d8AYwAmpxpz`.
61+
After a change to `Zoom Recaps to Slack.json` merges, load it with:
62+
63+
```bash
64+
N8N_API_KEY=<your key> ./scripts/update-workflow.sh "nodes/Zoom Recaps to Slack.json"
65+
```
66+
67+
The script sends the file to the n8n public API with a PUT.
68+
Check in the editor that the workflow is still active afterwards.

‎nodes/Zoom Recaps to Slack.json‎

Lines changed: 253 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
"httpMethod": "POST",
77
"path": "c8078642-e424-4e49-9165-328bb612f92d",
88
"responseMode": "responseNode",
9-
"options": {}
9+
"options": {
10+
"rawBody": true
11+
}
1012
},
1113
"id": "fe0c0dda-389f-4df5-8571-54e5e7010157",
1214
"name": "Webhook",
@@ -16,7 +18,168 @@
1618
"position": [
1719
0,
1820
-304
19-
]
21+
],
22+
"notes": "Zoom calls this URL. Raw Body is on because Zoom signs the exact bytes it sends, and the signature check needs them.",
23+
"notesInFlow": true
24+
},
25+
{
26+
"id": "a2000000-0000-0000-0000-000000000001",
27+
"name": "Read Raw Body",
28+
"type": "n8n-nodes-base.code",
29+
"typeVersion": 2,
30+
"position": [
31+
220,
32+
-304
33+
],
34+
"parameters": {
35+
"jsCode": "// Zoom signs the exact bytes it sent, so keep the raw request body alongside the parsed one.\n// (The Webhook node's Raw Body option stores those bytes as the binary property \"data\".)\nconst raw = (await this.helpers.getBinaryDataBuffer(0, 'data')).toString('utf8');\nreturn [{ json: { headers: $json.headers || {}, body: $json.body || {}, raw } }];\n"
36+
},
37+
"notes": "Keeps the raw request body next to the parsed one for the signature check.",
38+
"notesInFlow": true
39+
},
40+
{
41+
"id": "a2000000-0000-0000-0000-000000000002",
42+
"name": "Webhook Secret",
43+
"type": "n8n-nodes-base.dataTable",
44+
"typeVersion": 1.1,
45+
"position": [
46+
440,
47+
-304
48+
],
49+
"parameters": {
50+
"operation": "get",
51+
"dataTableId": {
52+
"__rl": true,
53+
"mode": "id",
54+
"value": "W8UuKtEGH0wqmzpt"
55+
},
56+
"returnAll": true,
57+
"filters": {
58+
"conditions": [
59+
{
60+
"keyName": "id",
61+
"condition": "gte",
62+
"keyValue": 0
63+
}
64+
]
65+
}
66+
},
67+
"notes": "Reads the Event Subscription secret token from the Data table 'Zoom Recaps - Webhook Secret' (column secretToken). The value lives in n8n only, never in this file.",
68+
"notesInFlow": true
69+
},
70+
{
71+
"id": "a2000000-0000-0000-0000-000000000003",
72+
"name": "Verify Zoom",
73+
"type": "n8n-nodes-base.code",
74+
"typeVersion": 2,
75+
"position": [
76+
660,
77+
-304
78+
],
79+
"parameters": {
80+
"jsCode": "// Decide how to answer Zoom, and whether this call should go on to post a recap.\n// - endpoint.url_validation: prove we hold the app's secret token by returning the HMAC of plainToken.\n// - anything else must carry a valid x-zm-signature (HMAC of \"v0:<timestamp>:<raw body>\"), so only\n// Zoom can trigger a post; a bad or missing signature gets a 401 and stops here.\n// - only meeting.summary_completed goes on to the posting path.\n// Zoom wants the answer within 3 seconds, so this node only decides; the slow work runs after the reply.\nconst crypto = require('crypto');\nconst req = $('Read Raw Body').first().json;\nconst secret = String(($('Webhook Secret').first().json || {}).secretToken || '').trim();\nconst hmac = (msg) => crypto.createHmac('sha256', secret).update(msg).digest('hex');\nconst body = req.body || {};\nconst event = body.event || '';\n\nif (!secret) return [{ json: { status: 500, response: { error: 'webhook secret not configured' }, proceed: false } }];\n\nif (event === 'endpoint.url_validation') {\n const plainToken = String((body.payload || {}).plainToken || '');\n return [{ json: { status: 200, response: { plainToken, encryptedToken: hmac(plainToken) }, proceed: false } }];\n}\n\nconst sent = String(req.headers['x-zm-signature'] || '');\nconst expected = 'v0=' + hmac('v0:' + (req.headers['x-zm-request-timestamp'] || '') + ':' + req.raw);\nconst valid = sent.length === expected.length && crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(expected));\nif (!valid) return [{ json: { status: 401, response: { error: 'invalid signature' }, proceed: false } }];\n\nconst obj = (body.payload || {}).object || {};\nreturn [{ json: {\n status: 200,\n response: { ok: true },\n proceed: event === 'meeting.summary_completed',\n meetingId: String(obj.meeting_id || obj.id || ''),\n meetingUuid: obj.meeting_uuid || obj.uuid || '',\n} }];\n"
81+
},
82+
"notes": "Answers Zoom's URL validation with the HMAC of its challenge, and requires a valid x-zm-signature on every other call (401 otherwise). Decides the reply; does not post.",
83+
"notesInFlow": true
84+
},
85+
{
86+
"id": "a2000000-0000-0000-0000-000000000004",
87+
"name": "Respond to Zoom",
88+
"type": "n8n-nodes-base.respondToWebhook",
89+
"typeVersion": 1.1,
90+
"position": [
91+
880,
92+
-304
93+
],
94+
"parameters": {
95+
"respondWith": "json",
96+
"responseBody": "={{ JSON.stringify($json.response) }}",
97+
"options": {
98+
"responseCode": "={{ $json.status }}"
99+
}
100+
},
101+
"notes": "Sends Verify Zoom's answer back to Zoom right away, inside Zoom's 3 second limit.",
102+
"notesInFlow": true
103+
},
104+
{
105+
"id": "a2000000-0000-0000-0000-000000000005",
106+
"name": "Summary Completed?",
107+
"type": "n8n-nodes-base.if",
108+
"typeVersion": 2.2,
109+
"position": [
110+
1100,
111+
-304
112+
],
113+
"parameters": {
114+
"conditions": {
115+
"options": {
116+
"caseSensitive": true,
117+
"leftValue": "",
118+
"typeValidation": "strict",
119+
"version": 2
120+
},
121+
"conditions": [
122+
{
123+
"leftValue": "={{ $json.proceed }}",
124+
"rightValue": true,
125+
"operator": {
126+
"type": "boolean",
127+
"operation": "true",
128+
"singleValue": true
129+
}
130+
}
131+
],
132+
"combinator": "and"
133+
},
134+
"options": {}
135+
},
136+
"notes": "Only a meeting.summary_completed event with a valid signature goes on.",
137+
"notesInFlow": true
138+
},
139+
{
140+
"id": "a2000000-0000-0000-0000-000000000006",
141+
"name": "Configured Meeting?",
142+
"type": "n8n-nodes-base.dataTable",
143+
"typeVersion": 1.1,
144+
"position": [
145+
1320,
146+
-304
147+
],
148+
"parameters": {
149+
"operation": "rowExists",
150+
"dataTableId": {
151+
"__rl": true,
152+
"mode": "id",
153+
"value": "zhSysUVAmqepqX5I"
154+
},
155+
"filters": {
156+
"conditions": [
157+
{
158+
"keyName": "meetingId",
159+
"keyValue": "={{ $json.meetingId }}"
160+
}
161+
]
162+
}
163+
},
164+
"notes": "Only a meeting listed in the Config Data table goes on.",
165+
"notesInFlow": true
166+
},
167+
{
168+
"id": "a2000000-0000-0000-0000-000000000007",
169+
"name": "Let Summary Settle",
170+
"type": "n8n-nodes-base.wait",
171+
"typeVersion": 1.1,
172+
"position": [
173+
1540,
174+
-304
175+
],
176+
"webhookId": "5f0e6c1e-6a52-4d0b-9a3e-2b7f4c9d8e11",
177+
"parameters": {
178+
"amount": 20,
179+
"unit": "minutes"
180+
},
181+
"notes": "Waits 20 minutes so Zoom finishes refining the summary, then joins the same posting path as the daily sweep.",
182+
"notesInFlow": true
20183
},
21184
{
22185
"parameters": {
@@ -979,6 +1142,94 @@
9791142
}
9801143
]
9811144
]
1145+
},
1146+
"Webhook": {
1147+
"main": [
1148+
[
1149+
{
1150+
"node": "Read Raw Body",
1151+
"type": "main",
1152+
"index": 0
1153+
}
1154+
]
1155+
]
1156+
},
1157+
"Read Raw Body": {
1158+
"main": [
1159+
[
1160+
{
1161+
"node": "Webhook Secret",
1162+
"type": "main",
1163+
"index": 0
1164+
}
1165+
]
1166+
]
1167+
},
1168+
"Webhook Secret": {
1169+
"main": [
1170+
[
1171+
{
1172+
"node": "Verify Zoom",
1173+
"type": "main",
1174+
"index": 0
1175+
}
1176+
]
1177+
]
1178+
},
1179+
"Verify Zoom": {
1180+
"main": [
1181+
[
1182+
{
1183+
"node": "Respond to Zoom",
1184+
"type": "main",
1185+
"index": 0
1186+
}
1187+
]
1188+
]
1189+
},
1190+
"Respond to Zoom": {
1191+
"main": [
1192+
[
1193+
{
1194+
"node": "Summary Completed?",
1195+
"type": "main",
1196+
"index": 0
1197+
}
1198+
]
1199+
]
1200+
},
1201+
"Summary Completed?": {
1202+
"main": [
1203+
[
1204+
{
1205+
"node": "Configured Meeting?",
1206+
"type": "main",
1207+
"index": 0
1208+
}
1209+
]
1210+
]
1211+
},
1212+
"Configured Meeting?": {
1213+
"main": [
1214+
[
1215+
{
1216+
"node": "Let Summary Settle",
1217+
"type": "main",
1218+
"index": 0
1219+
}
1220+
]
1221+
]
1222+
},
1223+
"Let Summary Settle": {
1224+
"main": [
1225+
[
1226+
{
1227+
"node": "Slack: users.list",
1228+
"type": "main",
1229+
"index": 0
1230+
}
1231+
]
1232+
]
9821233
}
9831234
},
9841235
"active": true,

0 commit comments

Comments
 (0)