@zyphr-dev/mcp-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,664 @@
1
+ import type { QuickstartChannelMap } from '../quickstart-types.js';
2
+
3
+ const DOCS = 'https://docs.zyphr.dev/features/webhooks-security';
4
+
5
+ const ENV: string[] = ['ZYPHR_WEBHOOK_SECRET'];
6
+
7
+ const NEXT_STEPS = [
8
+ 'Add ZYPHR_WEBHOOK_SECRET to your .env file — get it from `zyphr.webhooks.rotateWebhookSecret(id)` or the Zyphr dashboard.',
9
+ 'Configure the webhook endpoint URL in the Zyphr dashboard or via `create_webhook`.',
10
+ 'ALWAYS verify signatures before processing payloads — never trust an unverified webhook.',
11
+ 'Reject deliveries whose timestamp is more than 5 minutes from now to prevent replay attacks.',
12
+ ];
13
+
14
+ export const webhookChannel: QuickstartChannelMap = {
15
+ node: {
16
+ sdk: {
17
+ channel: 'webhook',
18
+ language: 'node',
19
+ framework: null,
20
+ variant: 'webhook-handler',
21
+ files: [
22
+ {
23
+ path: 'src/lib/verifyZyphrWebhook.ts',
24
+ purpose:
25
+ 'Standard Webhooks (HMAC-SHA256) signature + timestamp verification. Mirrors the canonical snippet in apps/docs/docs/features/webhooks-security.md.',
26
+ contents:
27
+ "import crypto from 'crypto';\n\n" +
28
+ 'export function verifyZyphrWebhook(\n' +
29
+ ' payload: string,\n' +
30
+ " headers: { 'webhook-id': string; 'webhook-timestamp': string; 'webhook-signature': string },\n" +
31
+ ' secret: string,\n' +
32
+ '): boolean {\n' +
33
+ " const msgId = headers['webhook-id'];\n" +
34
+ " const timestamp = parseInt(headers['webhook-timestamp'], 10);\n" +
35
+ " const signatures = headers['webhook-signature'];\n\n" +
36
+ ' const now = Math.floor(Date.now() / 1000);\n' +
37
+ ' if (Math.abs(now - timestamp) > 300) return false;\n\n' +
38
+ ' const signedContent = `${msgId}.${timestamp}.${payload}`;\n' +
39
+ ' const secretBytes = Buffer.from(\n' +
40
+ " secret.startsWith('whsec_') ? secret.slice(6) : secret,\n" +
41
+ " 'hex',\n" +
42
+ ' );\n' +
43
+ ' const expected = crypto\n' +
44
+ " .createHmac('sha256', secretBytes)\n" +
45
+ ' .update(signedContent)\n' +
46
+ " .digest('base64');\n\n" +
47
+ " for (const sig of signatures.split(' ')) {\n" +
48
+ ' const sigValue = sig.slice(3);\n' +
49
+ ' if (\n' +
50
+ ' sigValue.length === expected.length &&\n' +
51
+ ' crypto.timingSafeEqual(Buffer.from(sigValue), Buffer.from(expected))\n' +
52
+ ' ) {\n' +
53
+ ' return true;\n' +
54
+ ' }\n' +
55
+ ' }\n' +
56
+ ' return false;\n' +
57
+ '}\n',
58
+ overwrite: false,
59
+ },
60
+ ],
61
+ envVarsNeeded: ENV,
62
+ nextSteps: NEXT_STEPS,
63
+ docsUrl: DOCS,
64
+ },
65
+ frameworks: {
66
+ express: {
67
+ channel: 'webhook',
68
+ language: 'node',
69
+ framework: 'express',
70
+ variant: 'webhook-handler',
71
+ files: [
72
+ {
73
+ path: 'src/lib/verifyZyphrWebhook.ts',
74
+ purpose: 'Standard Webhooks signature verification helper',
75
+ contents:
76
+ "import crypto from 'crypto';\n\n" +
77
+ 'export function verifyZyphrWebhook(payload: string, headers: Record<string,string>, secret: string): boolean {\n' +
78
+ " const msgId = headers['webhook-id'];\n" +
79
+ " const timestamp = parseInt(headers['webhook-timestamp'], 10);\n" +
80
+ " const signatures = headers['webhook-signature'] || '';\n" +
81
+ ' const now = Math.floor(Date.now() / 1000);\n' +
82
+ ' if (Math.abs(now - timestamp) > 300) return false;\n' +
83
+ ' const signedContent = `${msgId}.${timestamp}.${payload}`;\n' +
84
+ " const secretBytes = Buffer.from(secret.startsWith('whsec_') ? secret.slice(6) : secret, 'hex');\n" +
85
+ " const expected = crypto.createHmac('sha256', secretBytes).update(signedContent).digest('base64');\n" +
86
+ " return signatures.split(' ').some((sig) => {\n" +
87
+ ' const v = sig.slice(3);\n' +
88
+ ' return v.length === expected.length && crypto.timingSafeEqual(Buffer.from(v), Buffer.from(expected));\n' +
89
+ ' });\n' +
90
+ '}\n',
91
+ overwrite: false,
92
+ },
93
+ {
94
+ path: 'src/routes/zyphrWebhook.ts',
95
+ purpose:
96
+ 'Express route that VERIFIES the signature before processing the webhook. Uses express.raw() so we can hash the exact bytes.',
97
+ contents:
98
+ "import { Router, raw } from 'express';\n" +
99
+ "import { verifyZyphrWebhook } from '../lib/verifyZyphrWebhook.js';\n\n" +
100
+ 'export const zyphrWebhookRouter = Router();\n\n' +
101
+ "zyphrWebhookRouter.post('/zyphr', raw({ type: 'application/json' }), (req, res) => {\n" +
102
+ ' const payload = (req.body as Buffer).toString();\n' +
103
+ ' const headers = req.headers as Record<string, string>;\n' +
104
+ ' if (!verifyZyphrWebhook(payload, headers, process.env.ZYPHR_WEBHOOK_SECRET!)) {\n' +
105
+ " return res.status(401).send('invalid signature');\n" +
106
+ ' }\n' +
107
+ ' const event = JSON.parse(payload) as { type: string; data: unknown };\n' +
108
+ " console.log('zyphr event', event.type);\n" +
109
+ ' res.sendStatus(204);\n' +
110
+ '});\n',
111
+ overwrite: false,
112
+ },
113
+ ],
114
+ envVarsNeeded: ENV,
115
+ nextSteps: [
116
+ ...NEXT_STEPS,
117
+ 'Mount the router BEFORE express.json(): app.use(zyphrWebhookRouter)',
118
+ ],
119
+ docsUrl: DOCS,
120
+ },
121
+ nextjs: {
122
+ channel: 'webhook',
123
+ language: 'node',
124
+ framework: 'nextjs',
125
+ variant: 'webhook-handler',
126
+ files: [
127
+ {
128
+ path: 'src/lib/verifyZyphrWebhook.ts',
129
+ purpose: 'Standard Webhooks signature verification helper',
130
+ contents:
131
+ "import crypto from 'crypto';\n\n" +
132
+ 'export function verifyZyphrWebhook(payload: string, headers: Headers, secret: string): boolean {\n' +
133
+ " const msgId = headers.get('webhook-id') || '';\n" +
134
+ " const timestamp = parseInt(headers.get('webhook-timestamp') || '0', 10);\n" +
135
+ " const signatures = headers.get('webhook-signature') || '';\n" +
136
+ ' const now = Math.floor(Date.now() / 1000);\n' +
137
+ ' if (Math.abs(now - timestamp) > 300) return false;\n' +
138
+ ' const signedContent = `${msgId}.${timestamp}.${payload}`;\n' +
139
+ " const secretBytes = Buffer.from(secret.startsWith('whsec_') ? secret.slice(6) : secret, 'hex');\n" +
140
+ " const expected = crypto.createHmac('sha256', secretBytes).update(signedContent).digest('base64');\n" +
141
+ " return signatures.split(' ').some((sig) => {\n" +
142
+ ' const v = sig.slice(3);\n' +
143
+ ' return v.length === expected.length && crypto.timingSafeEqual(Buffer.from(v), Buffer.from(expected));\n' +
144
+ ' });\n' +
145
+ '}\n',
146
+ overwrite: false,
147
+ },
148
+ {
149
+ path: 'src/app/api/zyphr/webhook/route.ts',
150
+ purpose: 'Next.js App Router webhook handler with signature verification',
151
+ contents:
152
+ "import { NextResponse } from 'next/server';\n" +
153
+ "import { verifyZyphrWebhook } from '@/lib/verifyZyphrWebhook';\n\n" +
154
+ 'export async function POST(req: Request) {\n' +
155
+ ' const payload = await req.text();\n' +
156
+ ' if (!verifyZyphrWebhook(payload, req.headers, process.env.ZYPHR_WEBHOOK_SECRET!)) {\n' +
157
+ " return new NextResponse('invalid signature', { status: 401 });\n" +
158
+ ' }\n' +
159
+ ' const event = JSON.parse(payload) as { type: string; data: unknown };\n' +
160
+ " console.log('zyphr event', event.type);\n" +
161
+ ' return new NextResponse(null, { status: 204 });\n' +
162
+ '}\n',
163
+ overwrite: false,
164
+ },
165
+ ],
166
+ envVarsNeeded: ENV,
167
+ nextSteps: NEXT_STEPS,
168
+ docsUrl: DOCS,
169
+ },
170
+ },
171
+ },
172
+ python: {
173
+ sdk: {
174
+ channel: 'webhook',
175
+ language: 'python',
176
+ framework: null,
177
+ variant: 'webhook-handler',
178
+ files: [
179
+ {
180
+ path: 'app/zyphr_webhook.py',
181
+ purpose: 'Standard Webhooks signature verification helper (verbatim from docs)',
182
+ contents:
183
+ 'import hashlib\n' +
184
+ 'import hmac\n' +
185
+ 'import base64\n' +
186
+ 'import time\n\n' +
187
+ 'def verify_zyphr_webhook(payload: str, headers: dict, secret: str) -> bool:\n' +
188
+ ' msg_id = headers.get("webhook-id", "")\n' +
189
+ ' timestamp = headers.get("webhook-timestamp", "")\n' +
190
+ ' signature = headers.get("webhook-signature", "")\n\n' +
191
+ ' now = int(time.time())\n' +
192
+ ' if abs(now - int(timestamp)) > 300:\n' +
193
+ ' return False\n\n' +
194
+ ' signed_content = f"{msg_id}.{timestamp}.{payload}"\n' +
195
+ ' secret_hex = secret.removeprefix("whsec_")\n' +
196
+ ' secret_bytes = bytes.fromhex(secret_hex)\n' +
197
+ ' expected = base64.b64encode(\n' +
198
+ ' hmac.new(secret_bytes, signed_content.encode(), hashlib.sha256).digest()\n' +
199
+ ' ).decode()\n\n' +
200
+ ' for sig in signature.split(" "):\n' +
201
+ ' sig_value = sig.removeprefix("v1,")\n' +
202
+ ' if hmac.compare_digest(sig_value, expected):\n' +
203
+ ' return True\n' +
204
+ ' return False\n',
205
+ overwrite: false,
206
+ },
207
+ ],
208
+ envVarsNeeded: ENV,
209
+ nextSteps: NEXT_STEPS,
210
+ docsUrl: DOCS,
211
+ },
212
+ frameworks: {
213
+ flask: {
214
+ channel: 'webhook',
215
+ language: 'python',
216
+ framework: 'flask',
217
+ variant: 'webhook-handler',
218
+ files: [
219
+ {
220
+ path: 'app/zyphr_webhook.py',
221
+ purpose: 'Standard Webhooks signature verification helper',
222
+ contents:
223
+ 'import hashlib, hmac, base64, time\n\n' +
224
+ 'def verify_zyphr_webhook(payload: str, headers, secret: str) -> bool:\n' +
225
+ ' msg_id = headers.get("webhook-id", "")\n' +
226
+ ' timestamp = headers.get("webhook-timestamp", "")\n' +
227
+ ' signature = headers.get("webhook-signature", "")\n' +
228
+ ' if abs(int(time.time()) - int(timestamp)) > 300:\n' +
229
+ ' return False\n' +
230
+ ' signed = f"{msg_id}.{timestamp}.{payload}"\n' +
231
+ ' secret_bytes = bytes.fromhex(secret.removeprefix("whsec_"))\n' +
232
+ ' expected = base64.b64encode(hmac.new(secret_bytes, signed.encode(), hashlib.sha256).digest()).decode()\n' +
233
+ ' return any(hmac.compare_digest(sig.removeprefix("v1,"), expected) for sig in signature.split(" "))\n',
234
+ overwrite: false,
235
+ },
236
+ {
237
+ path: 'app/routes/zyphr_webhook.py',
238
+ purpose: 'Flask blueprint that verifies the webhook signature before processing',
239
+ contents:
240
+ 'import os, json\n' +
241
+ 'from flask import Blueprint, request, abort\n' +
242
+ 'from ..zyphr_webhook import verify_zyphr_webhook\n\n' +
243
+ 'zyphr_webhook_bp = Blueprint("zyphr_webhook", __name__)\n\n' +
244
+ '@zyphr_webhook_bp.route("/webhooks/zyphr", methods=["POST"])\n' +
245
+ 'def handle():\n' +
246
+ ' payload = request.get_data(as_text=True)\n' +
247
+ ' if not verify_zyphr_webhook(payload, request.headers, os.environ["ZYPHR_WEBHOOK_SECRET"]):\n' +
248
+ ' abort(401, "invalid signature")\n' +
249
+ ' event = json.loads(payload)\n' +
250
+ ' print("zyphr event", event.get("type"))\n' +
251
+ ' return "", 204\n',
252
+ overwrite: false,
253
+ },
254
+ ],
255
+ envVarsNeeded: ENV,
256
+ nextSteps: NEXT_STEPS,
257
+ docsUrl: DOCS,
258
+ },
259
+ fastapi: {
260
+ channel: 'webhook',
261
+ language: 'python',
262
+ framework: 'fastapi',
263
+ variant: 'webhook-handler',
264
+ files: [
265
+ {
266
+ path: 'app/routers/zyphr_webhook.py',
267
+ purpose: 'FastAPI router that verifies the webhook signature before processing',
268
+ contents:
269
+ 'import os, json, hashlib, hmac, base64, time\n' +
270
+ 'from fastapi import APIRouter, Request, HTTPException\n\n' +
271
+ 'router = APIRouter()\n\n' +
272
+ 'def verify_zyphr_webhook(payload: str, headers, secret: str) -> bool:\n' +
273
+ ' msg_id = headers.get("webhook-id", "")\n' +
274
+ ' timestamp = headers.get("webhook-timestamp", "")\n' +
275
+ ' signature = headers.get("webhook-signature", "")\n' +
276
+ ' if abs(int(time.time()) - int(timestamp)) > 300:\n' +
277
+ ' return False\n' +
278
+ ' signed = f"{msg_id}.{timestamp}.{payload}"\n' +
279
+ ' secret_bytes = bytes.fromhex(secret.removeprefix("whsec_"))\n' +
280
+ ' expected = base64.b64encode(hmac.new(secret_bytes, signed.encode(), hashlib.sha256).digest()).decode()\n' +
281
+ ' return any(hmac.compare_digest(sig.removeprefix("v1,"), expected) for sig in signature.split(" "))\n\n' +
282
+ '@router.post("/webhooks/zyphr")\n' +
283
+ 'async def handle(request: Request):\n' +
284
+ ' body = (await request.body()).decode()\n' +
285
+ ' if not verify_zyphr_webhook(body, request.headers, os.environ["ZYPHR_WEBHOOK_SECRET"]):\n' +
286
+ ' raise HTTPException(status_code=401, detail="invalid signature")\n' +
287
+ ' event = json.loads(body)\n' +
288
+ ' print("zyphr event", event.get("type"))\n' +
289
+ ' return {"ok": True}\n',
290
+ overwrite: false,
291
+ },
292
+ ],
293
+ envVarsNeeded: ENV,
294
+ nextSteps: NEXT_STEPS,
295
+ docsUrl: DOCS,
296
+ },
297
+ },
298
+ },
299
+ ruby: {
300
+ sdk: {
301
+ channel: 'webhook',
302
+ language: 'ruby',
303
+ framework: null,
304
+ variant: 'webhook-handler',
305
+ files: [
306
+ {
307
+ path: 'app/services/zyphr_webhook.rb',
308
+ purpose: 'Standard Webhooks signature verification helper',
309
+ contents:
310
+ "require 'openssl'\n" +
311
+ "require 'base64'\n\n" +
312
+ 'class ZyphrWebhook\n' +
313
+ ' def self.verify(payload:, headers:, secret:)\n' +
314
+ " msg_id = headers['webhook-id'].to_s\n" +
315
+ " timestamp = headers['webhook-timestamp'].to_i\n" +
316
+ " signatures = headers['webhook-signature'].to_s\n\n" +
317
+ ' return false if (Time.now.to_i - timestamp).abs > 300\n\n' +
318
+ ' signed = "#{msg_id}.#{timestamp}.#{payload}"\n' +
319
+ " hex = secret.start_with?('whsec_') ? secret[6..] : secret\n" +
320
+ ' secret_bytes = [hex].pack(\'H*\')\n' +
321
+ " expected = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret_bytes, signed))\n\n" +
322
+ " signatures.split(' ').any? do |sig|\n" +
323
+ ' v = sig[3..]\n' +
324
+ ' v && Rack::Utils.secure_compare(v, expected)\n' +
325
+ ' end\n' +
326
+ ' end\n' +
327
+ 'end\n',
328
+ overwrite: false,
329
+ },
330
+ ],
331
+ envVarsNeeded: ENV,
332
+ nextSteps: NEXT_STEPS,
333
+ docsUrl: DOCS,
334
+ },
335
+ frameworks: {
336
+ rails: {
337
+ channel: 'webhook',
338
+ language: 'ruby',
339
+ framework: 'rails',
340
+ variant: 'webhook-handler',
341
+ files: [
342
+ {
343
+ path: 'app/services/zyphr_webhook.rb',
344
+ purpose: 'Standard Webhooks signature verification helper',
345
+ contents:
346
+ "require 'openssl'\n" +
347
+ "require 'base64'\n\n" +
348
+ 'class ZyphrWebhook\n' +
349
+ ' def self.verify(payload:, headers:, secret:)\n' +
350
+ " msg_id = headers['webhook-id'].to_s\n" +
351
+ " timestamp = headers['webhook-timestamp'].to_i\n" +
352
+ " signatures = headers['webhook-signature'].to_s\n" +
353
+ ' return false if (Time.now.to_i - timestamp).abs > 300\n' +
354
+ ' signed = "#{msg_id}.#{timestamp}.#{payload}"\n' +
355
+ " hex = secret.start_with?('whsec_') ? secret[6..] : secret\n" +
356
+ ' secret_bytes = [hex].pack(\'H*\')\n' +
357
+ " expected = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret_bytes, signed))\n" +
358
+ " signatures.split(' ').any? { |sig| sig[3..] && ActiveSupport::SecurityUtils.secure_compare(sig[3..], expected) }\n" +
359
+ ' end\n' +
360
+ 'end\n',
361
+ overwrite: false,
362
+ },
363
+ {
364
+ path: 'app/controllers/zyphr_webhooks_controller.rb',
365
+ purpose: 'Rails controller that verifies the webhook signature before processing',
366
+ contents:
367
+ 'class ZyphrWebhooksController < ApplicationController\n' +
368
+ ' skip_before_action :verify_authenticity_token\n\n' +
369
+ ' def create\n' +
370
+ " payload = request.raw_post\n" +
371
+ " secret = ENV.fetch('ZYPHR_WEBHOOK_SECRET')\n" +
372
+ ' unless ZyphrWebhook.verify(payload: payload, headers: request.headers, secret: secret)\n' +
373
+ ' head :unauthorized and return\n' +
374
+ ' end\n' +
375
+ ' event = JSON.parse(payload)\n' +
376
+ " Rails.logger.info(\"zyphr event #{event['type']}\")\n" +
377
+ ' head :no_content\n' +
378
+ ' end\n' +
379
+ 'end\n',
380
+ overwrite: false,
381
+ },
382
+ ],
383
+ envVarsNeeded: ENV,
384
+ nextSteps: [
385
+ ...NEXT_STEPS,
386
+ "Add route: post '/webhooks/zyphr', to: 'zyphr_webhooks#create'",
387
+ ],
388
+ docsUrl: DOCS,
389
+ },
390
+ },
391
+ },
392
+ go: {
393
+ sdk: {
394
+ channel: 'webhook',
395
+ language: 'go',
396
+ framework: null,
397
+ variant: 'webhook-handler',
398
+ files: [
399
+ {
400
+ path: 'internal/zyphr/verify.go',
401
+ purpose:
402
+ 'Standard Webhooks signature verification helper (verbatim from docs).',
403
+ contents:
404
+ 'package zyphr\n\n' +
405
+ 'import (\n' +
406
+ '\t"crypto/hmac"\n' +
407
+ '\t"crypto/sha256"\n' +
408
+ '\t"encoding/base64"\n' +
409
+ '\t"encoding/hex"\n' +
410
+ '\t"math"\n' +
411
+ '\t"strconv"\n' +
412
+ '\t"strings"\n' +
413
+ '\t"time"\n' +
414
+ ')\n\n' +
415
+ 'func VerifyWebhook(payload, msgID, timestamp, signature, secret string) bool {\n' +
416
+ '\tts, err := strconv.ParseInt(timestamp, 10, 64)\n' +
417
+ '\tif err != nil { return false }\n' +
418
+ '\tif math.Abs(float64(time.Now().Unix()-ts)) > 300 { return false }\n\n' +
419
+ '\tsigned := msgID + "." + timestamp + "." + payload\n' +
420
+ '\tsecretHex := strings.TrimPrefix(secret, "whsec_")\n' +
421
+ '\tsecretBytes, err := hex.DecodeString(secretHex)\n' +
422
+ '\tif err != nil { return false }\n' +
423
+ '\tmac := hmac.New(sha256.New, secretBytes)\n' +
424
+ '\tmac.Write([]byte(signed))\n' +
425
+ '\texpected := base64.StdEncoding.EncodeToString(mac.Sum(nil))\n\n' +
426
+ '\tfor _, sig := range strings.Split(signature, " ") {\n' +
427
+ '\t\tv := strings.TrimPrefix(sig, "v1,")\n' +
428
+ '\t\tif hmac.Equal([]byte(v), []byte(expected)) { return true }\n' +
429
+ '\t}\n' +
430
+ '\treturn false\n' +
431
+ '}\n',
432
+ overwrite: false,
433
+ },
434
+ ],
435
+ envVarsNeeded: ENV,
436
+ nextSteps: NEXT_STEPS,
437
+ docsUrl: DOCS,
438
+ },
439
+ },
440
+ php: {
441
+ sdk: {
442
+ channel: 'webhook',
443
+ language: 'php',
444
+ framework: null,
445
+ variant: 'webhook-handler',
446
+ files: [
447
+ {
448
+ path: 'app/Webhooks/ZyphrWebhook.php',
449
+ purpose: 'Standard Webhooks signature verification helper (verbatim from docs)',
450
+ contents:
451
+ '<?php\n\n' +
452
+ 'namespace App\\Webhooks;\n\n' +
453
+ 'class ZyphrWebhook\n' +
454
+ '{\n' +
455
+ ' public static function verify(string $payload, array $headers, string $secret): bool\n' +
456
+ ' {\n' +
457
+ " $msgId = $headers['webhook-id'] ?? '';\n" +
458
+ " $timestamp = $headers['webhook-timestamp'] ?? '';\n" +
459
+ " $signature = $headers['webhook-signature'] ?? '';\n" +
460
+ ' if (abs(time() - intval($timestamp)) > 300) {\n' +
461
+ ' return false;\n' +
462
+ ' }\n' +
463
+ ' $signed = "{$msgId}.{$timestamp}.{$payload}";\n' +
464
+ " $hex = str_starts_with($secret, 'whsec_') ? substr($secret, 6) : $secret;\n" +
465
+ " $expected = base64_encode(hash_hmac('sha256', $signed, hex2bin($hex), true));\n" +
466
+ " foreach (explode(' ', $signature) as $sig) {\n" +
467
+ ' if (hash_equals(substr($sig, 3), $expected)) {\n' +
468
+ ' return true;\n' +
469
+ ' }\n' +
470
+ ' }\n' +
471
+ ' return false;\n' +
472
+ ' }\n' +
473
+ '}\n',
474
+ overwrite: false,
475
+ },
476
+ ],
477
+ envVarsNeeded: ENV,
478
+ nextSteps: NEXT_STEPS,
479
+ docsUrl: DOCS,
480
+ },
481
+ frameworks: {
482
+ laravel: {
483
+ channel: 'webhook',
484
+ language: 'php',
485
+ framework: 'laravel',
486
+ variant: 'webhook-handler',
487
+ files: [
488
+ {
489
+ path: 'app/Webhooks/ZyphrWebhook.php',
490
+ purpose: 'Standard Webhooks signature verification helper',
491
+ contents:
492
+ '<?php\n\n' +
493
+ 'namespace App\\Webhooks;\n\n' +
494
+ 'class ZyphrWebhook\n' +
495
+ '{\n' +
496
+ ' public static function verify(string $payload, array $headers, string $secret): bool\n' +
497
+ ' {\n' +
498
+ " $msgId = $headers['webhook-id'][0] ?? '';\n" +
499
+ " $timestamp = $headers['webhook-timestamp'][0] ?? '';\n" +
500
+ " $signature = $headers['webhook-signature'][0] ?? '';\n" +
501
+ ' if (abs(time() - intval($timestamp)) > 300) {\n' +
502
+ ' return false;\n' +
503
+ ' }\n' +
504
+ ' $signed = "{$msgId}.{$timestamp}.{$payload}";\n' +
505
+ " $hex = str_starts_with($secret, 'whsec_') ? substr($secret, 6) : $secret;\n" +
506
+ " $expected = base64_encode(hash_hmac('sha256', $signed, hex2bin($hex), true));\n" +
507
+ " foreach (explode(' ', $signature) as $sig) {\n" +
508
+ ' if (hash_equals(substr($sig, 3), $expected)) {\n' +
509
+ ' return true;\n' +
510
+ ' }\n' +
511
+ ' }\n' +
512
+ ' return false;\n' +
513
+ ' }\n' +
514
+ '}\n',
515
+ overwrite: false,
516
+ },
517
+ {
518
+ path: 'app/Http/Controllers/ZyphrWebhookController.php',
519
+ purpose: 'Laravel controller that verifies the webhook signature before processing',
520
+ contents:
521
+ '<?php\n\n' +
522
+ 'namespace App\\Http\\Controllers;\n\n' +
523
+ 'use App\\Webhooks\\ZyphrWebhook;\n' +
524
+ 'use Illuminate\\Http\\Request;\n\n' +
525
+ 'class ZyphrWebhookController extends Controller\n' +
526
+ '{\n' +
527
+ ' public function handle(Request $request)\n' +
528
+ ' {\n' +
529
+ " $payload = $request->getContent();\n" +
530
+ " $secret = config('services.zyphr.webhook_secret');\n" +
531
+ " if (! ZyphrWebhook::verify($payload, $request->headers->all(), $secret)) {\n" +
532
+ " return response('invalid signature', 401);\n" +
533
+ ' }\n' +
534
+ " $event = json_decode($payload, true);\n" +
535
+ " \\Log::info('zyphr event ' . ($event['type'] ?? 'unknown'));\n" +
536
+ " return response()->noContent();\n" +
537
+ ' }\n' +
538
+ '}\n',
539
+ overwrite: false,
540
+ },
541
+ ],
542
+ envVarsNeeded: ENV,
543
+ nextSteps: [
544
+ ...NEXT_STEPS,
545
+ "Register route in routes/api.php: Route::post('/webhooks/zyphr', [ZyphrWebhookController::class, 'handle']);",
546
+ "Exclude this route from CSRF (VerifyCsrfToken::$except).",
547
+ ],
548
+ docsUrl: DOCS,
549
+ },
550
+ },
551
+ },
552
+ csharp: {
553
+ sdk: {
554
+ channel: 'webhook',
555
+ language: 'csharp',
556
+ framework: null,
557
+ variant: 'webhook-handler',
558
+ files: [
559
+ {
560
+ path: 'Webhooks/ZyphrWebhookVerifier.cs',
561
+ purpose: 'Standard Webhooks signature verification helper',
562
+ contents:
563
+ 'using System.Security.Cryptography;\n' +
564
+ 'using System.Text;\n\n' +
565
+ 'namespace YourApp.Webhooks;\n\n' +
566
+ 'public static class ZyphrWebhookVerifier\n' +
567
+ '{\n' +
568
+ ' public static bool Verify(string payload, IDictionary<string,string> headers, string secret)\n' +
569
+ ' {\n' +
570
+ ' var msgId = headers.TryGetValue("webhook-id", out var id) ? id : "";\n' +
571
+ ' var timestamp = headers.TryGetValue("webhook-timestamp", out var ts) ? long.Parse(ts) : 0;\n' +
572
+ ' var signatures = headers.TryGetValue("webhook-signature", out var sig) ? sig : "";\n\n' +
573
+ ' var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();\n' +
574
+ ' if (Math.Abs(now - timestamp) > 300) return false;\n\n' +
575
+ ' var signed = $"{msgId}.{timestamp}.{payload}";\n' +
576
+ ' var hex = secret.StartsWith("whsec_") ? secret[6..] : secret;\n' +
577
+ ' var secretBytes = Convert.FromHexString(hex);\n' +
578
+ ' using var hmac = new HMACSHA256(secretBytes);\n' +
579
+ ' var expected = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(signed)));\n\n' +
580
+ ' foreach (var s in signatures.Split(\' \'))\n' +
581
+ ' {\n' +
582
+ ' var v = s.Length > 3 ? s[3..] : "";\n' +
583
+ ' if (CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(v), Encoding.UTF8.GetBytes(expected)))\n' +
584
+ ' return true;\n' +
585
+ ' }\n' +
586
+ ' return false;\n' +
587
+ ' }\n' +
588
+ '}\n',
589
+ overwrite: false,
590
+ },
591
+ ],
592
+ envVarsNeeded: ENV,
593
+ nextSteps: NEXT_STEPS,
594
+ docsUrl: DOCS,
595
+ },
596
+ frameworks: {
597
+ aspnetcore: {
598
+ channel: 'webhook',
599
+ language: 'csharp',
600
+ framework: 'aspnetcore',
601
+ variant: 'webhook-handler',
602
+ files: [
603
+ {
604
+ path: 'Webhooks/ZyphrWebhookVerifier.cs',
605
+ purpose: 'Standard Webhooks signature verification helper',
606
+ contents:
607
+ 'using System.Security.Cryptography;\n' +
608
+ 'using System.Text;\n\n' +
609
+ 'namespace YourApp.Webhooks;\n\n' +
610
+ 'public static class ZyphrWebhookVerifier\n' +
611
+ '{\n' +
612
+ ' public static bool Verify(string payload, IHeaderDictionary headers, string secret)\n' +
613
+ ' {\n' +
614
+ ' string msgId = headers["webhook-id"].ToString();\n' +
615
+ ' long timestamp = long.TryParse(headers["webhook-timestamp"], out var t) ? t : 0;\n' +
616
+ ' string signatures = headers["webhook-signature"].ToString();\n' +
617
+ ' if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp) > 300) return false;\n' +
618
+ ' var signed = $"{msgId}.{timestamp}.{payload}";\n' +
619
+ ' var hex = secret.StartsWith("whsec_") ? secret[6..] : secret;\n' +
620
+ ' using var hmac = new HMACSHA256(Convert.FromHexString(hex));\n' +
621
+ ' var expected = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(signed)));\n' +
622
+ ' foreach (var s in signatures.Split(\' \'))\n' +
623
+ ' {\n' +
624
+ ' var v = s.Length > 3 ? s[3..] : "";\n' +
625
+ ' if (CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(v), Encoding.UTF8.GetBytes(expected)))\n' +
626
+ ' return true;\n' +
627
+ ' }\n' +
628
+ ' return false;\n' +
629
+ ' }\n' +
630
+ '}\n',
631
+ overwrite: false,
632
+ },
633
+ {
634
+ path: 'Controllers/ZyphrWebhookController.cs',
635
+ purpose: 'ASP.NET Core controller that verifies the webhook signature before processing',
636
+ contents:
637
+ 'using Microsoft.AspNetCore.Mvc;\n' +
638
+ 'using YourApp.Webhooks;\n\n' +
639
+ '[ApiController]\n' +
640
+ '[Route("webhooks/zyphr")]\n' +
641
+ 'public class ZyphrWebhookController : ControllerBase\n' +
642
+ '{\n' +
643
+ ' [HttpPost]\n' +
644
+ ' public async Task<IActionResult> Handle()\n' +
645
+ ' {\n' +
646
+ ' using var reader = new StreamReader(Request.Body);\n' +
647
+ ' var payload = await reader.ReadToEndAsync();\n' +
648
+ ' var secret = Environment.GetEnvironmentVariable("ZYPHR_WEBHOOK_SECRET")!;\n' +
649
+ ' if (!ZyphrWebhookVerifier.Verify(payload, Request.Headers, secret))\n' +
650
+ ' return Unauthorized("invalid signature");\n' +
651
+ ' Console.WriteLine($"zyphr event {payload[..Math.Min(80, payload.Length)]}");\n' +
652
+ ' return NoContent();\n' +
653
+ ' }\n' +
654
+ '}\n',
655
+ overwrite: false,
656
+ },
657
+ ],
658
+ envVarsNeeded: ENV,
659
+ nextSteps: NEXT_STEPS,
660
+ docsUrl: DOCS,
661
+ },
662
+ },
663
+ },
664
+ };