@sayna-ai/node-sdk 0.0.5 → 0.0.7

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.
package/README.md CHANGED
@@ -52,6 +52,7 @@ Performs a health check on the Sayna server.
52
52
  **Returns**: `Promise<{ status: string }>` - Status object with "OK" when healthy.
53
53
 
54
54
  **Example**:
55
+
55
56
  ```typescript
56
57
  const health = await client.health();
57
58
  console.log(health.status); // "OK"
@@ -64,10 +65,14 @@ Retrieves the catalogue of text-to-speech voices grouped by provider.
64
65
  **Returns**: `Promise<Record<string, Voice[]>>` - Object where keys are provider names and values are arrays of voice descriptors.
65
66
 
66
67
  **Example**:
68
+
67
69
  ```typescript
68
70
  const voices = await client.getVoices();
69
71
  for (const [provider, voiceList] of Object.entries(voices)) {
70
- console.log(`${provider}:`, voiceList.map(v => v.name));
72
+ console.log(
73
+ `${provider}:`,
74
+ voiceList.map((v) => v.name)
75
+ );
71
76
  }
72
77
  ```
73
78
 
@@ -75,14 +80,15 @@ for (const [provider, voiceList] of Object.entries(voices)) {
75
80
 
76
81
  Synthesizes text into audio using the REST API. This is a standalone method that doesn't require an active WebSocket connection.
77
82
 
78
- | parameter | type | purpose |
79
- | --- | --- | --- |
80
- | `text` | `string` | Text to synthesize (must be non-empty). |
81
- | `ttsConfig` | `TTSConfig` | Text-to-speech provider configuration. |
83
+ | parameter | type | purpose |
84
+ | ----------- | ----------- | --------------------------------------- |
85
+ | `text` | `string` | Text to synthesize (must be non-empty). |
86
+ | `ttsConfig` | `TTSConfig` | Text-to-speech provider configuration. |
82
87
 
83
88
  **Returns**: `Promise<ArrayBuffer>` - Raw audio data.
84
89
 
85
90
  **Example**:
91
+
86
92
  ```typescript
87
93
  const audioBuffer = await client.speakRest("Hello, world!", {
88
94
  provider: "elevenlabs",
@@ -93,7 +99,7 @@ const audioBuffer = await client.speakRest("Hello, world!", {
93
99
  sample_rate: 24000,
94
100
  connection_timeout: 30,
95
101
  request_timeout: 60,
96
- pronunciations: []
102
+ pronunciations: [],
97
103
  });
98
104
  ```
99
105
 
@@ -101,15 +107,16 @@ const audioBuffer = await client.speakRest("Hello, world!", {
101
107
 
102
108
  Issues a LiveKit access token for a participant.
103
109
 
104
- | parameter | type | purpose |
105
- | --- | --- | --- |
106
- | `roomName` | `string` | LiveKit room to join or create. |
107
- | `participantName` | `string` | Display name for the participant. |
110
+ | parameter | type | purpose |
111
+ | --------------------- | -------- | -------------------------------------- |
112
+ | `roomName` | `string` | LiveKit room to join or create. |
113
+ | `participantName` | `string` | Display name for the participant. |
108
114
  | `participantIdentity` | `string` | Unique identifier for the participant. |
109
115
 
110
116
  **Returns**: `Promise<LiveKitTokenResponse>` - Object containing token, room name, participant identity, and LiveKit URL.
111
117
 
112
118
  **Example**:
119
+
113
120
  ```typescript
114
121
  const tokenInfo = await client.getLiveKitToken(
115
122
  "my-room",
@@ -120,6 +127,48 @@ console.log("Token:", tokenInfo.token);
120
127
  console.log("LiveKit URL:", tokenInfo.livekit_url);
121
128
  ```
122
129
 
130
+ ### `await client.getSipHooks()`
131
+
132
+ Retrieves all configured SIP webhook hooks from the runtime cache.
133
+
134
+ **Returns**: `Promise<SipHooksResponse>` - Object containing an array of configured hooks.
135
+
136
+ **Example**:
137
+
138
+ ```typescript
139
+ const response = await client.getSipHooks();
140
+ for (const hook of response.hooks) {
141
+ console.log(`Host: ${hook.host}, URL: ${hook.url}`);
142
+ }
143
+ ```
144
+
145
+ ### `await client.setSipHooks(hooks)`
146
+
147
+ Sets or updates SIP webhook hooks in the runtime cache. Hooks with matching hosts will be replaced; new hosts will be added.
148
+
149
+ | parameter | type | purpose |
150
+ | --------- | ----------- | ---------------------------------------- |
151
+ | `hooks` | `SipHook[]` | Array of SIP hook configurations to set. |
152
+
153
+ Each `SipHook` object contains:
154
+
155
+ | field | type | description |
156
+ | ------ | -------- | ---------------------------------------- |
157
+ | `host` | `string` | SIP domain pattern (case-insensitive). |
158
+ | `url` | `string` | HTTPS URL to forward webhook events to. |
159
+
160
+ **Returns**: `Promise<SipHooksResponse>` - Object containing the merged list of all configured hooks.
161
+
162
+ **Example**:
163
+
164
+ ```typescript
165
+ const response = await client.setSipHooks([
166
+ { host: "example.com", url: "https://webhook.example.com/events" },
167
+ { host: "another.com", url: "https://webhook.another.com/events" },
168
+ ]);
169
+ console.log("Total hooks configured:", response.hooks.length);
170
+ ```
171
+
123
172
  ---
124
173
 
125
174
  ### WebSocket API Methods
@@ -128,13 +177,13 @@ These methods require an active WebSocket connection:
128
177
 
129
178
  ### `new SaynaClient(url, sttConfig, ttsConfig, livekitConfig?, withoutAudio?)`
130
179
 
131
- | parameter | type | purpose |
132
- | --- | --- | --- |
133
- | `url` | `string` | Sayna server URL (http://, https://, ws://, or wss://). |
134
- | `sttConfig` | `STTConfig` | Speech-to-text provider configuration. |
135
- | `ttsConfig` | `TTSConfig` | Text-to-speech provider configuration. |
136
- | `livekitConfig` | `LiveKitConfig` | Optional LiveKit room configuration. |
137
- | `withoutAudio` | `boolean` | Disable audio streaming (defaults to `false`). |
180
+ | parameter | type | purpose |
181
+ | --------------- | --------------- | ------------------------------------------------------- |
182
+ | `url` | `string` | Sayna server URL (http://, https://, ws://, or wss://). |
183
+ | `sttConfig` | `STTConfig` | Speech-to-text provider configuration. |
184
+ | `ttsConfig` | `TTSConfig` | Text-to-speech provider configuration. |
185
+ | `livekitConfig` | `LiveKitConfig` | Optional LiveKit room configuration. |
186
+ | `withoutAudio` | `boolean` | Disable audio streaming (defaults to `false`). |
138
187
 
139
188
  ### `await client.connect()`
140
189
 
@@ -168,11 +217,11 @@ Registers a callback for TTS playback completion events.
168
217
 
169
218
  Sends text to be synthesized as speech.
170
219
 
171
- | parameter | type | default | purpose |
172
- | --- | --- | --- | --- |
173
- | `text` | `string` | - | Text to synthesize. |
174
- | `flush` | `boolean` | `true` | Clear TTS queue before speaking. |
175
- | `allowInterruption` | `boolean` | `true` | Allow speech to be interrupted. |
220
+ | parameter | type | default | purpose |
221
+ | ------------------- | --------- | ------- | -------------------------------- |
222
+ | `text` | `string` | - | Text to synthesize. |
223
+ | `flush` | `boolean` | `true` | Clear TTS queue before speaking. |
224
+ | `allowInterruption` | `boolean` | `true` | Allow speech to be interrupted. |
176
225
 
177
226
  ### `await client.onAudioInput(audioData)`
178
227
 
@@ -218,6 +267,133 @@ Identity assigned to the agent participant when LiveKit is enabled, if available
218
267
 
219
268
  Display name assigned to the agent participant when LiveKit is enabled, if available.
220
269
 
270
+ ---
271
+
272
+ ## Webhook Receiver
273
+
274
+ The SDK includes a `WebhookReceiver` class for securely receiving and verifying cryptographically signed webhooks from Sayna's SIP service.
275
+
276
+ ### Security Features
277
+
278
+ - **HMAC-SHA256 Signature Verification**: Ensures webhook authenticity
279
+ - **Constant-Time Comparison**: Prevents timing attack vulnerabilities
280
+ - **Replay Protection**: 5-minute timestamp window prevents replay attacks
281
+ - **Strict Validation**: Comprehensive checks on all required fields
282
+
283
+ ### `new WebhookReceiver(secret?)`
284
+
285
+ Creates a new webhook receiver instance.
286
+
287
+ | parameter | type | purpose |
288
+ | --------- | -------- | ---------------------------------------------------------------------------- |
289
+ | `secret` | `string` | HMAC signing secret (min 16 chars). Defaults to `SAYNA_WEBHOOK_SECRET` env. |
290
+
291
+ **Example**:
292
+
293
+ ```typescript
294
+ import { WebhookReceiver } from "@sayna/node-sdk";
295
+
296
+ // Explicit secret
297
+ const receiver = new WebhookReceiver("your-secret-key-min-16-chars");
298
+
299
+ // Or use environment variable
300
+ process.env.SAYNA_WEBHOOK_SECRET = "your-secret-key";
301
+ const receiver = new WebhookReceiver();
302
+ ```
303
+
304
+ ### `receiver.receive(headers, body)`
305
+
306
+ Verifies and parses an incoming SIP webhook.
307
+
308
+ | parameter | type | purpose |
309
+ | --------- | ------------------------------------------- | -------------------------------------------- |
310
+ | `headers` | `Record<string, string \| string[] \| undefined>` | HTTP request headers (case-insensitive). |
311
+ | `body` | `string` | Raw request body as string (not parsed JSON). |
312
+
313
+ **Returns**: `WebhookSIPOutput` - Parsed and validated webhook payload.
314
+
315
+ **Throws**: `SaynaValidationError` if signature verification fails or payload is invalid.
316
+
317
+ ### Express Example
318
+
319
+ ```typescript
320
+ import express from "express";
321
+ import { WebhookReceiver } from "@sayna/node-sdk";
322
+
323
+ const app = express();
324
+ const receiver = new WebhookReceiver("your-secret-key-min-16-chars");
325
+
326
+ app.post(
327
+ "/webhook",
328
+ express.json({
329
+ verify: (req, res, buf) => {
330
+ (req as any).rawBody = buf.toString("utf8");
331
+ },
332
+ }),
333
+ (req, res) => {
334
+ try {
335
+ const webhook = receiver.receive(req.headers, (req as any).rawBody);
336
+
337
+ console.log("Valid webhook received:");
338
+ console.log(" From:", webhook.from_phone_number);
339
+ console.log(" To:", webhook.to_phone_number);
340
+ console.log(" Room:", webhook.room.name);
341
+ console.log(" SIP Host:", webhook.sip_host);
342
+ console.log(" Participant:", webhook.participant.identity);
343
+
344
+ res.status(200).json({ received: true });
345
+ } catch (error) {
346
+ console.error("Webhook verification failed:", error.message);
347
+ res.status(401).json({ error: "Invalid signature" });
348
+ }
349
+ }
350
+ );
351
+ ```
352
+
353
+ ### Fastify Example
354
+
355
+ ```typescript
356
+ import Fastify from "fastify";
357
+ import { WebhookReceiver } from "@sayna/node-sdk";
358
+
359
+ const fastify = Fastify();
360
+ const receiver = new WebhookReceiver();
361
+
362
+ fastify.post(
363
+ "/webhook",
364
+ {
365
+ config: { rawBody: true },
366
+ },
367
+ async (request, reply) => {
368
+ try {
369
+ const webhook = receiver.receive(request.headers, request.rawBody);
370
+ return { received: true };
371
+ } catch (error) {
372
+ reply.code(401);
373
+ return { error: error.message };
374
+ }
375
+ }
376
+ );
377
+ ```
378
+
379
+ ### WebhookSIPOutput Type
380
+
381
+ The `receive` method returns a `WebhookSIPOutput` object with the following structure:
382
+
383
+ | field | type | description |
384
+ | ------------------- | ----------------------- | -------------------------------------------------- |
385
+ | `participant` | `WebhookSIPParticipant` | SIP participant information. |
386
+ | `participant.identity` | `string` | Unique identity assigned to the participant. |
387
+ | `participant.sid` | `string` | Participant session ID from LiveKit. |
388
+ | `participant.name` | `string?` | Display name (optional). |
389
+ | `room` | `WebhookSIPRoom` | LiveKit room information. |
390
+ | `room.name` | `string` | Name of the LiveKit room. |
391
+ | `room.sid` | `string` | Room session ID from LiveKit. |
392
+ | `from_phone_number` | `string` | Caller's phone number (E.164 format). |
393
+ | `to_phone_number` | `string` | Called phone number (E.164 format). |
394
+ | `room_prefix` | `string` | Room name prefix configured in Sayna. |
395
+ | `sip_host` | `string` | SIP domain extracted from the To header. |
396
+
221
397
  ## Development
222
398
 
223
399
  ```bash
package/dist/index.js CHANGED
@@ -56,6 +56,7 @@ class SaynaClient {
56
56
  ttsConfig;
57
57
  livekitConfig;
58
58
  withoutAudio;
59
+ apiKey;
59
60
  websocket;
60
61
  isConnected = false;
61
62
  isReady = false;
@@ -71,7 +72,7 @@ class SaynaClient {
71
72
  ttsPlaybackCompleteCallback;
72
73
  readyPromiseResolve;
73
74
  readyPromiseReject;
74
- constructor(url, sttConfig, ttsConfig, livekitConfig, withoutAudio = false) {
75
+ constructor(url, sttConfig, ttsConfig, livekitConfig, withoutAudio = false, apiKey) {
75
76
  if (!url || typeof url !== "string") {
76
77
  throw new SaynaValidationError("URL must be a non-empty string");
77
78
  }
@@ -88,6 +89,7 @@ class SaynaClient {
88
89
  this.ttsConfig = ttsConfig;
89
90
  this.livekitConfig = livekitConfig;
90
91
  this.withoutAudio = withoutAudio;
92
+ this.apiKey = apiKey ?? process.env.SAYNA_API_KEY;
91
93
  }
92
94
  async connect() {
93
95
  if (this.isConnected) {
@@ -98,7 +100,9 @@ class SaynaClient {
98
100
  this.readyPromiseResolve = resolve;
99
101
  this.readyPromiseReject = reject;
100
102
  try {
101
- this.websocket = new WebSocket(wsUrl);
103
+ const headers = this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : undefined;
104
+ const WebSocketConstructor = WebSocket;
105
+ this.websocket = headers ? new WebSocketConstructor(wsUrl, undefined, { headers }) : new WebSocket(wsUrl);
102
106
  this.websocket.onopen = () => {
103
107
  this.isConnected = true;
104
108
  const configMessage = {
@@ -128,14 +132,18 @@ class SaynaClient {
128
132
  await this.ttsCallback(buffer);
129
133
  }
130
134
  } else {
135
+ if (typeof event.data !== "string") {
136
+ throw new Error("Expected string data for JSON messages");
137
+ }
131
138
  const data = JSON.parse(event.data);
132
139
  await this.handleJsonMessage(data);
133
140
  }
134
141
  } catch (error) {
135
142
  if (this.errorCallback) {
143
+ const errorMessage = error instanceof Error ? error.message : String(error);
136
144
  await this.errorCallback({
137
145
  type: "error",
138
- message: `Failed to process message: ${error instanceof Error ? error.message : String(error)}`
146
+ message: `Failed to process message: ${errorMessage}`
139
147
  });
140
148
  }
141
149
  }
@@ -239,6 +247,10 @@ class SaynaClient {
239
247
  const headers = {
240
248
  ...options.headers
241
249
  };
250
+ const hasAuthHeader = Object.keys(headers).some((key) => key.toLowerCase() === "authorization");
251
+ if (this.apiKey && !hasAuthHeader) {
252
+ headers["Authorization"] = `Bearer ${this.apiKey}`;
253
+ }
242
254
  if (options.method === "POST" && options.body && !headers["Content-Type"]) {
243
255
  headers["Content-Type"] = "application/json";
244
256
  }
@@ -248,10 +260,14 @@ class SaynaClient {
248
260
  headers
249
261
  });
250
262
  if (!response.ok) {
251
- const errorData = await response.json().catch(() => ({
252
- error: response.statusText
253
- }));
254
- throw new SaynaServerError(errorData?.error ?? `Request failed: ${response.status} ${response.statusText}`);
263
+ let errorMessage;
264
+ try {
265
+ const errorData = await response.json();
266
+ errorMessage = errorData && typeof errorData === "object" && "error" in errorData && typeof errorData.error === "string" ? errorData.error : `Request failed: ${response.status} ${response.statusText}`;
267
+ } catch {
268
+ errorMessage = `Request failed: ${response.status} ${response.statusText}`;
269
+ }
270
+ throw new SaynaServerError(errorMessage);
255
271
  }
256
272
  if (responseType === "arrayBuffer") {
257
273
  return await response.arrayBuffer();
@@ -265,7 +281,7 @@ class SaynaClient {
265
281
  throw new SaynaConnectionError(`Failed to fetch from ${endpoint}`, error);
266
282
  }
267
283
  }
268
- async disconnect() {
284
+ disconnect() {
269
285
  if (this.websocket) {
270
286
  this.websocket.onopen = null;
271
287
  this.websocket.onmessage = null;
@@ -278,7 +294,7 @@ class SaynaClient {
278
294
  }
279
295
  this.cleanup();
280
296
  }
281
- async onAudioInput(audioData) {
297
+ onAudioInput(audioData) {
282
298
  if (!this.isConnected || !this.websocket) {
283
299
  throw new SaynaNotConnectedError;
284
300
  }
@@ -315,7 +331,7 @@ class SaynaClient {
315
331
  registerOnTtsPlaybackComplete(callback) {
316
332
  this.ttsPlaybackCompleteCallback = callback;
317
333
  }
318
- async speak(text, flush = true, allowInterruption = true) {
334
+ speak(text, flush = true, allowInterruption = true) {
319
335
  if (!this.isConnected || !this.websocket) {
320
336
  throw new SaynaNotConnectedError;
321
337
  }
@@ -337,7 +353,7 @@ class SaynaClient {
337
353
  throw new SaynaConnectionError("Failed to send speak command", error);
338
354
  }
339
355
  }
340
- async clear() {
356
+ clear() {
341
357
  if (!this.isConnected || !this.websocket) {
342
358
  throw new SaynaNotConnectedError;
343
359
  }
@@ -353,10 +369,10 @@ class SaynaClient {
353
369
  throw new SaynaConnectionError("Failed to send clear command", error);
354
370
  }
355
371
  }
356
- async ttsFlush(allowInterruption = true) {
357
- await this.speak("", true, allowInterruption);
372
+ ttsFlush(allowInterruption = true) {
373
+ this.speak("", true, allowInterruption);
358
374
  }
359
- async sendMessage(message, role, topic, debug) {
375
+ sendMessage(message, role, topic, debug) {
360
376
  if (!this.isConnected || !this.websocket) {
361
377
  throw new SaynaNotConnectedError;
362
378
  }
@@ -383,16 +399,16 @@ class SaynaClient {
383
399
  }
384
400
  }
385
401
  async health() {
386
- return await this.fetchFromSayna("");
402
+ return this.fetchFromSayna("");
387
403
  }
388
404
  async getVoices() {
389
- return await this.fetchFromSayna("voices");
405
+ return this.fetchFromSayna("voices");
390
406
  }
391
407
  async speakRest(text, ttsConfig) {
392
408
  if (!text || text.trim().length === 0) {
393
409
  throw new SaynaValidationError("Text cannot be empty");
394
410
  }
395
- return await this.fetchFromSayna("speak", {
411
+ return this.fetchFromSayna("speak", {
396
412
  method: "POST",
397
413
  body: JSON.stringify({
398
414
  text,
@@ -410,7 +426,7 @@ class SaynaClient {
410
426
  if (!participantIdentity || participantIdentity.trim().length === 0) {
411
427
  throw new SaynaValidationError("participant_identity cannot be empty");
412
428
  }
413
- return await this.fetchFromSayna("livekit/token", {
429
+ return this.fetchFromSayna("livekit/token", {
414
430
  method: "POST",
415
431
  body: JSON.stringify({
416
432
  room_name: roomName,
@@ -419,6 +435,29 @@ class SaynaClient {
419
435
  })
420
436
  });
421
437
  }
438
+ async getSipHooks() {
439
+ return this.fetchFromSayna("sip/hooks");
440
+ }
441
+ async setSipHooks(hooks) {
442
+ if (!Array.isArray(hooks)) {
443
+ throw new SaynaValidationError("hooks must be an array");
444
+ }
445
+ if (hooks.length === 0) {
446
+ throw new SaynaValidationError("hooks array cannot be empty");
447
+ }
448
+ for (const [i, hook] of hooks.entries()) {
449
+ if (!hook.host || typeof hook.host !== "string" || hook.host.trim().length === 0) {
450
+ throw new SaynaValidationError(`hooks[${i}].host must be a non-empty string`);
451
+ }
452
+ if (!hook.url || typeof hook.url !== "string" || hook.url.trim().length === 0) {
453
+ throw new SaynaValidationError(`hooks[${i}].url must be a non-empty string`);
454
+ }
455
+ }
456
+ return this.fetchFromSayna("sip/hooks", {
457
+ method: "POST",
458
+ body: JSON.stringify({ hooks })
459
+ });
460
+ }
422
461
  get ready() {
423
462
  return this.isReady;
424
463
  }
@@ -438,15 +477,139 @@ class SaynaClient {
438
477
  return this._saynaParticipantName;
439
478
  }
440
479
  }
480
+ // src/webhook-receiver.ts
481
+ import { createHmac, timingSafeEqual } from "crypto";
482
+ var MIN_SECRET_LENGTH = 16;
483
+ var TIMESTAMP_TOLERANCE_SECONDS = 300;
484
+
485
+ class WebhookReceiver {
486
+ secret;
487
+ constructor(secret) {
488
+ const effectiveSecret = secret ?? process.env.SAYNA_WEBHOOK_SECRET;
489
+ if (!effectiveSecret) {
490
+ throw new SaynaValidationError("Webhook secret is required. Provide it as a constructor parameter or set SAYNA_WEBHOOK_SECRET environment variable.");
491
+ }
492
+ const trimmedSecret = effectiveSecret.trim();
493
+ if (trimmedSecret.length < MIN_SECRET_LENGTH) {
494
+ throw new SaynaValidationError(`Webhook secret must be at least ${MIN_SECRET_LENGTH} characters long. ` + `Received ${trimmedSecret.length} characters. ` + `Generate a secure secret with: openssl rand -hex 32`);
495
+ }
496
+ this.secret = trimmedSecret;
497
+ }
498
+ receive(headers, body) {
499
+ const normalizedHeaders = this.normalizeHeaders(headers);
500
+ const signature = this.getRequiredHeader(normalizedHeaders, "x-sayna-signature");
501
+ const timestamp = this.getRequiredHeader(normalizedHeaders, "x-sayna-timestamp");
502
+ const eventId = this.getRequiredHeader(normalizedHeaders, "x-sayna-event-id");
503
+ if (!signature.startsWith("v1=")) {
504
+ throw new SaynaValidationError("Invalid signature format. Expected 'v1=<hex>' but got: " + signature.substring(0, 10) + "...");
505
+ }
506
+ const signatureHex = signature.substring(3);
507
+ if (!/^[0-9a-f]{64}$/i.test(signatureHex)) {
508
+ throw new SaynaValidationError("Invalid signature: must be 64 hex characters (HMAC-SHA256)");
509
+ }
510
+ this.validateTimestamp(timestamp);
511
+ const canonical = `v1:${timestamp}:${eventId}:${body}`;
512
+ const hmac = createHmac("sha256", this.secret);
513
+ hmac.update(canonical, "utf8");
514
+ const expectedSignature = hmac.digest("hex");
515
+ if (!this.constantTimeEqual(signatureHex, expectedSignature)) {
516
+ throw new SaynaValidationError("Signature verification failed. The webhook may have been tampered with or the secret is incorrect.");
517
+ }
518
+ return this.parseAndValidatePayload(body);
519
+ }
520
+ normalizeHeaders(headers) {
521
+ const normalized = {};
522
+ for (const [key, value] of Object.entries(headers)) {
523
+ if (value !== undefined) {
524
+ const stringValue = Array.isArray(value) ? value[0] : value;
525
+ if (stringValue) {
526
+ normalized[key.toLowerCase()] = stringValue;
527
+ }
528
+ }
529
+ }
530
+ return normalized;
531
+ }
532
+ getRequiredHeader(headers, name) {
533
+ const value = headers[name.toLowerCase()];
534
+ if (!value) {
535
+ throw new SaynaValidationError(`Missing required header: ${name}`);
536
+ }
537
+ return value;
538
+ }
539
+ validateTimestamp(timestampStr) {
540
+ const timestamp = Number(timestampStr);
541
+ if (isNaN(timestamp)) {
542
+ throw new SaynaValidationError(`Invalid timestamp format: expected Unix seconds but got '${timestampStr}'`);
543
+ }
544
+ const now = Math.floor(Date.now() / 1000);
545
+ const diff = Math.abs(now - timestamp);
546
+ if (diff > TIMESTAMP_TOLERANCE_SECONDS) {
547
+ throw new SaynaValidationError(`Timestamp outside replay protection window. ` + `Difference: ${diff} seconds (max allowed: ${TIMESTAMP_TOLERANCE_SECONDS}). ` + `This webhook may be a replay attack or there may be significant clock skew.`);
548
+ }
549
+ }
550
+ constantTimeEqual(a, b) {
551
+ if (a.length !== b.length) {
552
+ return false;
553
+ }
554
+ const bufA = Buffer.from(a, "utf8");
555
+ const bufB = Buffer.from(b, "utf8");
556
+ return timingSafeEqual(bufA, bufB);
557
+ }
558
+ parseAndValidatePayload(body) {
559
+ let payload;
560
+ try {
561
+ payload = JSON.parse(body);
562
+ } catch (error) {
563
+ throw new SaynaValidationError(`Invalid JSON payload: ${error instanceof Error ? error.message : String(error)}`);
564
+ }
565
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
566
+ throw new SaynaValidationError("Webhook payload must be a JSON object");
567
+ }
568
+ const data = payload;
569
+ this.validateParticipant(data.participant);
570
+ this.validateRoom(data.room);
571
+ this.validateStringField(data, "from_phone_number", "from_phone_number");
572
+ this.validateStringField(data, "to_phone_number", "to_phone_number");
573
+ this.validateStringField(data, "room_prefix", "room_prefix");
574
+ this.validateStringField(data, "sip_host", "sip_host");
575
+ return data;
576
+ }
577
+ validateParticipant(participant) {
578
+ if (!participant || typeof participant !== "object" || Array.isArray(participant)) {
579
+ throw new SaynaValidationError("Webhook payload missing required field 'participant' (must be an object)");
580
+ }
581
+ const p = participant;
582
+ this.validateStringField(p, "identity", "participant.identity");
583
+ this.validateStringField(p, "sid", "participant.sid");
584
+ if (p.name !== undefined && typeof p.name !== "string") {
585
+ throw new SaynaValidationError("Field 'participant.name' must be a string if present");
586
+ }
587
+ }
588
+ validateRoom(room) {
589
+ if (!room || typeof room !== "object" || Array.isArray(room)) {
590
+ throw new SaynaValidationError("Webhook payload missing required field 'room' (must be an object)");
591
+ }
592
+ const r = room;
593
+ this.validateStringField(r, "name", "room.name");
594
+ this.validateStringField(r, "sid", "room.sid");
595
+ }
596
+ validateStringField(obj, field, displayName) {
597
+ const value = obj[field];
598
+ if (typeof value !== "string" || value.length === 0) {
599
+ throw new SaynaValidationError(`Webhook payload missing required field '${displayName}' (must be a non-empty string)`);
600
+ }
601
+ }
602
+ }
441
603
 
442
604
  // src/index.ts
443
- async function saynaConnect(url, sttConfig, ttsConfig, livekitConfig, withoutAudio = false) {
444
- const client = new SaynaClient(url, sttConfig, ttsConfig, livekitConfig, withoutAudio);
605
+ async function saynaConnect(url, sttConfig, ttsConfig, livekitConfig, withoutAudio = false, apiKey) {
606
+ const client = new SaynaClient(url, sttConfig, ttsConfig, livekitConfig, withoutAudio, apiKey);
445
607
  await client.connect();
446
608
  return client;
447
609
  }
448
610
  export {
449
611
  saynaConnect,
612
+ WebhookReceiver,
450
613
  SaynaValidationError,
451
614
  SaynaServerError,
452
615
  SaynaNotReadyError,
@@ -456,4 +619,4 @@ export {
456
619
  SaynaClient
457
620
  };
458
621
 
459
- //# debugId=8028B4D3DBBE95F564756E2164756E21
622
+ //# debugId=DBDEB289893BB9C064756E2164756E21