|
6 | 6 | "httpMethod": "POST", |
7 | 7 | "path": "c8078642-e424-4e49-9165-328bb612f92d", |
8 | 8 | "responseMode": "responseNode", |
9 | | - "options": {} |
| 9 | + "options": { |
| 10 | + "rawBody": true |
| 11 | + } |
10 | 12 | }, |
11 | 13 | "id": "fe0c0dda-389f-4df5-8571-54e5e7010157", |
12 | 14 | "name": "Webhook", |
|
16 | 18 | "position": [ |
17 | 19 | 0, |
18 | 20 | -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 |
20 | 183 | }, |
21 | 184 | { |
22 | 185 | "parameters": { |
|
979 | 1142 | } |
980 | 1143 | ] |
981 | 1144 | ] |
| 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 | + ] |
982 | 1233 | } |
983 | 1234 | }, |
984 | 1235 | "active": true, |
|
0 commit comments