@openclaw/voice-call 2026.2.24 → 2026.3.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.3.1
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.2.26
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
15
+ ## 2026.2.25
16
+
17
+ ### Changes
18
+
19
+ - Version alignment with core OpenClaw release numbers.
20
+
3
21
  ## 2026.2.24
4
22
 
5
23
  ### Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/voice-call",
3
- "version": "2026.2.24",
3
+ "version": "2026.3.1",
4
4
  "description": "OpenClaw voice-call plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getHeader } from "./http-headers.js";
3
+
4
+ describe("getHeader", () => {
5
+ it("returns first value when header is an array", () => {
6
+ expect(getHeader({ "x-test": ["first", "second"] }, "x-test")).toBe("first");
7
+ });
8
+
9
+ it("matches headers case-insensitively", () => {
10
+ expect(getHeader({ "X-Twilio-Signature": "sig-1" }, "x-twilio-signature")).toBe("sig-1");
11
+ });
12
+
13
+ it("returns undefined for missing header", () => {
14
+ expect(getHeader({ host: "example.com" }, "x-missing")).toBeUndefined();
15
+ });
16
+ });
@@ -0,0 +1,12 @@
1
+ export type HttpHeaderMap = Record<string, string | string[] | undefined>;
2
+
3
+ export function getHeader(headers: HttpHeaderMap, name: string): string | undefined {
4
+ const target = name.toLowerCase();
5
+ const direct = headers[target];
6
+ const value =
7
+ direct ?? Object.entries(headers).find(([key]) => key.toLowerCase() === target)?.[1];
8
+ if (Array.isArray(value)) {
9
+ return value[0];
10
+ }
11
+ return value;
12
+ }
@@ -4,6 +4,7 @@ import type {
4
4
  InitiateCallResult,
5
5
  PlayTtsInput,
6
6
  ProviderName,
7
+ WebhookParseOptions,
7
8
  ProviderWebhookParseResult,
8
9
  StartListeningInput,
9
10
  StopListeningInput,
@@ -36,7 +37,7 @@ export interface VoiceCallProvider {
36
37
  * Parse provider-specific webhook payload into normalized events.
37
38
  * Returns events and optional response to send back to provider.
38
39
  */
39
- parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult;
40
+ parseWebhookEvent(ctx: WebhookContext, options?: WebhookParseOptions): ProviderWebhookParseResult;
40
41
 
41
42
  /**
42
43
  * Initiate an outbound call.
@@ -6,6 +6,7 @@ import type {
6
6
  InitiateCallResult,
7
7
  NormalizedEvent,
8
8
  PlayTtsInput,
9
+ WebhookParseOptions,
9
10
  ProviderWebhookParseResult,
10
11
  StartListeningInput,
11
12
  StopListeningInput,
@@ -28,7 +29,10 @@ export class MockProvider implements VoiceCallProvider {
28
29
  return { ok: true };
29
30
  }
30
31
 
31
- parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult {
32
+ parseWebhookEvent(
33
+ ctx: WebhookContext,
34
+ _options?: WebhookParseOptions,
35
+ ): ProviderWebhookParseResult {
32
36
  try {
33
37
  const payload = JSON.parse(ctx.rawBody);
34
38
  const events: NormalizedEvent[] = [];
@@ -24,4 +24,26 @@ describe("PlivoProvider", () => {
24
24
  expect(result.providerResponseBody).toContain("<Wait");
25
25
  expect(result.providerResponseBody).toContain('length="300"');
26
26
  });
27
+
28
+ it("uses verified request key when provided", () => {
29
+ const provider = new PlivoProvider({
30
+ authId: "MA000000000000000000",
31
+ authToken: "test-token",
32
+ });
33
+
34
+ const result = provider.parseWebhookEvent(
35
+ {
36
+ headers: { host: "example.com", "x-plivo-signature-v3-nonce": "nonce-1" },
37
+ rawBody:
38
+ "CallUUID=call-uuid&CallStatus=in-progress&Direction=outbound&From=%2B15550000000&To=%2B15550000001&Event=StartApp",
39
+ url: "https://example.com/voice/webhook?provider=plivo&flow=answer&callId=internal-call-id",
40
+ method: "POST",
41
+ query: { provider: "plivo", flow: "answer", callId: "internal-call-id" },
42
+ },
43
+ { verifiedRequestKey: "plivo:v3:verified" },
44
+ );
45
+
46
+ expect(result.events).toHaveLength(1);
47
+ expect(result.events[0]?.dedupeKey).toBe("plivo:v3:verified");
48
+ });
27
49
  });
@@ -1,5 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
  import type { PlivoConfig, WebhookSecurityConfig } from "../config.js";
3
+ import { getHeader } from "../http-headers.js";
3
4
  import type {
4
5
  HangupCallInput,
5
6
  InitiateCallInput,
@@ -10,11 +11,13 @@ import type {
10
11
  StartListeningInput,
11
12
  StopListeningInput,
12
13
  WebhookContext,
14
+ WebhookParseOptions,
13
15
  WebhookVerificationResult,
14
16
  } from "../types.js";
15
17
  import { escapeXml } from "../voice-mapping.js";
16
18
  import { reconstructWebhookUrl, verifyPlivoWebhook } from "../webhook-security.js";
17
19
  import type { VoiceCallProvider } from "./base.js";
20
+ import { guardedJsonApiRequest } from "./shared/guarded-json-api.js";
18
21
 
19
22
  export interface PlivoProviderOptions {
20
23
  /** Override public URL origin for signature verification */
@@ -30,17 +33,6 @@ export interface PlivoProviderOptions {
30
33
  type PendingSpeak = { text: string; locale?: string };
31
34
  type PendingListen = { language?: string };
32
35
 
33
- function getHeader(
34
- headers: Record<string, string | string[] | undefined>,
35
- name: string,
36
- ): string | undefined {
37
- const value = headers[name.toLowerCase()];
38
- if (Array.isArray(value)) {
39
- return value[0];
40
- }
41
- return value;
42
- }
43
-
44
36
  function createPlivoRequestDedupeKey(ctx: WebhookContext): string {
45
37
  const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce");
46
38
  if (nonceV3) {
@@ -60,6 +52,7 @@ export class PlivoProvider implements VoiceCallProvider {
60
52
  private readonly authToken: string;
61
53
  private readonly baseUrl: string;
62
54
  private readonly options: PlivoProviderOptions;
55
+ private readonly apiHost: string;
63
56
 
64
57
  // Best-effort mapping between create-call request UUID and call UUID.
65
58
  private requestUuidToCallUuid = new Map<string, string>();
@@ -82,6 +75,7 @@ export class PlivoProvider implements VoiceCallProvider {
82
75
  this.authId = config.authId;
83
76
  this.authToken = config.authToken;
84
77
  this.baseUrl = `https://api.plivo.com/v1/Account/${this.authId}`;
78
+ this.apiHost = new URL(this.baseUrl).hostname;
85
79
  this.options = options;
86
80
  }
87
81
 
@@ -92,25 +86,19 @@ export class PlivoProvider implements VoiceCallProvider {
92
86
  allowNotFound?: boolean;
93
87
  }): Promise<T> {
94
88
  const { method, endpoint, body, allowNotFound } = params;
95
- const response = await fetch(`${this.baseUrl}${endpoint}`, {
89
+ return await guardedJsonApiRequest<T>({
90
+ url: `${this.baseUrl}${endpoint}`,
96
91
  method,
97
92
  headers: {
98
93
  Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`,
99
94
  "Content-Type": "application/json",
100
95
  },
101
- body: body ? JSON.stringify(body) : undefined,
96
+ body,
97
+ allowNotFound,
98
+ allowedHostnames: [this.apiHost],
99
+ auditContext: "voice-call.plivo.api",
100
+ errorPrefix: "Plivo API error",
102
101
  });
103
-
104
- if (!response.ok) {
105
- if (allowNotFound && response.status === 404) {
106
- return undefined as T;
107
- }
108
- const errorText = await response.text();
109
- throw new Error(`Plivo API error: ${response.status} ${errorText}`);
110
- }
111
-
112
- const text = await response.text();
113
- return text ? (JSON.parse(text) as T) : (undefined as T);
114
102
  }
115
103
 
116
104
  verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
@@ -127,10 +115,18 @@ export class PlivoProvider implements VoiceCallProvider {
127
115
  console.warn(`[plivo] Webhook verification failed: ${result.reason}`);
128
116
  }
129
117
 
130
- return { ok: result.ok, reason: result.reason, isReplay: result.isReplay };
118
+ return {
119
+ ok: result.ok,
120
+ reason: result.reason,
121
+ isReplay: result.isReplay,
122
+ verifiedRequestKey: result.verifiedRequestKey,
123
+ };
131
124
  }
132
125
 
133
- parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult {
126
+ parseWebhookEvent(
127
+ ctx: WebhookContext,
128
+ options?: WebhookParseOptions,
129
+ ): ProviderWebhookParseResult {
134
130
  const flow = typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : "";
135
131
 
136
132
  const parsed = this.parseBody(ctx.rawBody);
@@ -196,7 +192,7 @@ export class PlivoProvider implements VoiceCallProvider {
196
192
 
197
193
  // Normal events.
198
194
  const callIdFromQuery = this.getCallIdFromQuery(ctx);
199
- const dedupeKey = createPlivoRequestDedupeKey(ctx);
195
+ const dedupeKey = options?.verifiedRequestKey ?? createPlivoRequestDedupeKey(ctx);
200
196
  const event = this.normalizeEvent(parsed, callIdFromQuery, dedupeKey);
201
197
 
202
198
  return {
@@ -0,0 +1,42 @@
1
+ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk";
2
+
3
+ type GuardedJsonApiRequestParams = {
4
+ url: string;
5
+ method: "GET" | "POST" | "DELETE" | "PUT" | "PATCH";
6
+ headers: Record<string, string>;
7
+ body?: Record<string, unknown>;
8
+ allowNotFound?: boolean;
9
+ allowedHostnames: string[];
10
+ auditContext: string;
11
+ errorPrefix: string;
12
+ };
13
+
14
+ export async function guardedJsonApiRequest<T = unknown>(
15
+ params: GuardedJsonApiRequestParams,
16
+ ): Promise<T> {
17
+ const { response, release } = await fetchWithSsrFGuard({
18
+ url: params.url,
19
+ init: {
20
+ method: params.method,
21
+ headers: params.headers,
22
+ body: params.body ? JSON.stringify(params.body) : undefined,
23
+ },
24
+ policy: { allowedHostnames: params.allowedHostnames },
25
+ auditContext: params.auditContext,
26
+ });
27
+
28
+ try {
29
+ if (!response.ok) {
30
+ if (params.allowNotFound && response.status === 404) {
31
+ return undefined as T;
32
+ }
33
+ const errorText = await response.text();
34
+ throw new Error(`${params.errorPrefix}: ${response.status} ${errorText}`);
35
+ }
36
+
37
+ const text = await response.text();
38
+ return text ? (JSON.parse(text) as T) : (undefined as T);
39
+ } finally {
40
+ await release();
41
+ }
42
+ }
@@ -133,7 +133,34 @@ describe("TelnyxProvider.verifyWebhook", () => {
133
133
 
134
134
  expect(first.ok).toBe(true);
135
135
  expect(first.isReplay).toBeFalsy();
136
+ expect(first.verifiedRequestKey).toBeTruthy();
136
137
  expect(second.ok).toBe(true);
137
138
  expect(second.isReplay).toBe(true);
139
+ expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
140
+ });
141
+ });
142
+
143
+ describe("TelnyxProvider.parseWebhookEvent", () => {
144
+ it("uses verified request key for manager dedupe", () => {
145
+ const provider = new TelnyxProvider({
146
+ apiKey: "KEY123",
147
+ connectionId: "CONN456",
148
+ publicKey: undefined,
149
+ });
150
+ const result = provider.parseWebhookEvent(
151
+ createCtx({
152
+ rawBody: JSON.stringify({
153
+ data: {
154
+ id: "evt-123",
155
+ event_type: "call.initiated",
156
+ payload: { call_control_id: "call-1" },
157
+ },
158
+ }),
159
+ }),
160
+ { verifiedRequestKey: "telnyx:req:abc" },
161
+ );
162
+
163
+ expect(result.events).toHaveLength(1);
164
+ expect(result.events[0]?.dedupeKey).toBe("telnyx:req:abc");
138
165
  });
139
166
  });
@@ -11,10 +11,12 @@ import type {
11
11
  StartListeningInput,
12
12
  StopListeningInput,
13
13
  WebhookContext,
14
+ WebhookParseOptions,
14
15
  WebhookVerificationResult,
15
16
  } from "../types.js";
16
17
  import { verifyTelnyxWebhook } from "../webhook-security.js";
17
18
  import type { VoiceCallProvider } from "./base.js";
19
+ import { guardedJsonApiRequest } from "./shared/guarded-json-api.js";
18
20
 
19
21
  /**
20
22
  * Telnyx Voice API provider implementation.
@@ -35,6 +37,7 @@ export class TelnyxProvider implements VoiceCallProvider {
35
37
  private readonly publicKey: string | undefined;
36
38
  private readonly options: TelnyxProviderOptions;
37
39
  private readonly baseUrl = "https://api.telnyx.com/v2";
40
+ private readonly apiHost = "api.telnyx.com";
38
41
 
39
42
  constructor(config: TelnyxConfig, options: TelnyxProviderOptions = {}) {
40
43
  if (!config.apiKey) {
@@ -58,25 +61,19 @@ export class TelnyxProvider implements VoiceCallProvider {
58
61
  body: Record<string, unknown>,
59
62
  options?: { allowNotFound?: boolean },
60
63
  ): Promise<T> {
61
- const response = await fetch(`${this.baseUrl}${endpoint}`, {
64
+ return await guardedJsonApiRequest<T>({
65
+ url: `${this.baseUrl}${endpoint}`,
62
66
  method: "POST",
63
67
  headers: {
64
68
  Authorization: `Bearer ${this.apiKey}`,
65
69
  "Content-Type": "application/json",
66
70
  },
67
- body: JSON.stringify(body),
71
+ body,
72
+ allowNotFound: options?.allowNotFound,
73
+ allowedHostnames: [this.apiHost],
74
+ auditContext: "voice-call.telnyx.api",
75
+ errorPrefix: "Telnyx API error",
68
76
  });
69
-
70
- if (!response.ok) {
71
- if (options?.allowNotFound && response.status === 404) {
72
- return undefined as T;
73
- }
74
- const errorText = await response.text();
75
- throw new Error(`Telnyx API error: ${response.status} ${errorText}`);
76
- }
77
-
78
- const text = await response.text();
79
- return text ? (JSON.parse(text) as T) : (undefined as T);
80
77
  }
81
78
 
82
79
  /**
@@ -87,13 +84,21 @@ export class TelnyxProvider implements VoiceCallProvider {
87
84
  skipVerification: this.options.skipVerification,
88
85
  });
89
86
 
90
- return { ok: result.ok, reason: result.reason, isReplay: result.isReplay };
87
+ return {
88
+ ok: result.ok,
89
+ reason: result.reason,
90
+ isReplay: result.isReplay,
91
+ verifiedRequestKey: result.verifiedRequestKey,
92
+ };
91
93
  }
92
94
 
93
95
  /**
94
96
  * Parse Telnyx webhook event into normalized format.
95
97
  */
96
- parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult {
98
+ parseWebhookEvent(
99
+ ctx: WebhookContext,
100
+ options?: WebhookParseOptions,
101
+ ): ProviderWebhookParseResult {
97
102
  try {
98
103
  const payload = JSON.parse(ctx.rawBody);
99
104
  const data = payload.data;
@@ -102,7 +107,7 @@ export class TelnyxProvider implements VoiceCallProvider {
102
107
  return { events: [], statusCode: 200 };
103
108
  }
104
109
 
105
- const event = this.normalizeEvent(data);
110
+ const event = this.normalizeEvent(data, options?.verifiedRequestKey);
106
111
  return {
107
112
  events: event ? [event] : [],
108
113
  statusCode: 200,
@@ -115,7 +120,7 @@ export class TelnyxProvider implements VoiceCallProvider {
115
120
  /**
116
121
  * Convert Telnyx event to normalized event format.
117
122
  */
118
- private normalizeEvent(data: TelnyxEvent): NormalizedEvent | null {
123
+ private normalizeEvent(data: TelnyxEvent, dedupeKey?: string): NormalizedEvent | null {
119
124
  // Decode client_state from Base64 (we encode it in initiateCall)
120
125
  let callId = "";
121
126
  if (data.payload?.client_state) {
@@ -132,6 +137,7 @@ export class TelnyxProvider implements VoiceCallProvider {
132
137
 
133
138
  const baseEvent = {
134
139
  id: data.id || crypto.randomUUID(),
140
+ dedupeKey,
135
141
  callId,
136
142
  providerCallId: data.payload?.call_control_id,
137
143
  timestamp: Date.now(),
@@ -29,5 +29,6 @@ export function verifyTwilioProviderWebhook(params: {
29
29
  ok: result.ok,
30
30
  reason: result.reason,
31
31
  isReplay: result.isReplay,
32
+ verifiedRequestKey: result.verifiedRequestKey,
32
33
  };
33
34
  }
@@ -60,7 +60,7 @@ describe("TwilioProvider", () => {
60
60
  expect(result.providerResponseBody).toContain("<Connect>");
61
61
  });
62
62
 
63
- it("uses a stable dedupeKey for identical request payloads", () => {
63
+ it("uses a stable fallback dedupeKey for identical request payloads", () => {
64
64
  const provider = createProvider();
65
65
  const rawBody = "CallSid=CA789&Direction=inbound&SpeechResult=hello";
66
66
  const ctxA = {
@@ -78,10 +78,31 @@ describe("TwilioProvider", () => {
78
78
  expect(eventA).toBeDefined();
79
79
  expect(eventB).toBeDefined();
80
80
  expect(eventA?.id).not.toBe(eventB?.id);
81
- expect(eventA?.dedupeKey).toBe("twilio:idempotency:idem-123");
81
+ expect(eventA?.dedupeKey).toContain("twilio:fallback:");
82
82
  expect(eventA?.dedupeKey).toBe(eventB?.dedupeKey);
83
83
  });
84
84
 
85
+ it("uses verified request key for dedupe and ignores idempotency header changes", () => {
86
+ const provider = createProvider();
87
+ const rawBody = "CallSid=CA790&Direction=inbound&SpeechResult=hello";
88
+ const ctxA = {
89
+ ...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }),
90
+ headers: { "i-twilio-idempotency-token": "idem-a" },
91
+ };
92
+ const ctxB = {
93
+ ...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }),
94
+ headers: { "i-twilio-idempotency-token": "idem-b" },
95
+ };
96
+
97
+ const eventA = provider.parseWebhookEvent(ctxA, { verifiedRequestKey: "twilio:req:abc" })
98
+ .events[0];
99
+ const eventB = provider.parseWebhookEvent(ctxB, { verifiedRequestKey: "twilio:req:abc" })
100
+ .events[0];
101
+
102
+ expect(eventA?.dedupeKey).toBe("twilio:req:abc");
103
+ expect(eventB?.dedupeKey).toBe("twilio:req:abc");
104
+ });
105
+
85
106
  it("keeps turnToken from query on speech events", () => {
86
107
  const provider = createProvider();
87
108
  const ctx = createContext("CallSid=CA222&Direction=inbound&SpeechResult=hello", {
@@ -1,5 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
  import type { TwilioConfig, WebhookSecurityConfig } from "../config.js";
3
+ import { getHeader } from "../http-headers.js";
3
4
  import type { MediaStreamHandler } from "../media-stream.js";
4
5
  import { chunkAudio } from "../telephony-audio.js";
5
6
  import type { TelephonyTtsProvider } from "../telephony-tts.js";
@@ -13,6 +14,7 @@ import type {
13
14
  StartListeningInput,
14
15
  StopListeningInput,
15
16
  WebhookContext,
17
+ WebhookParseOptions,
16
18
  WebhookVerificationResult,
17
19
  } from "../types.js";
18
20
  import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
@@ -20,30 +22,24 @@ import type { VoiceCallProvider } from "./base.js";
20
22
  import { twilioApiRequest } from "./twilio/api.js";
21
23
  import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
22
24
 
23
- function getHeader(
24
- headers: Record<string, string | string[] | undefined>,
25
- name: string,
26
- ): string | undefined {
27
- const value = headers[name.toLowerCase()];
28
- if (Array.isArray(value)) {
29
- return value[0];
30
- }
31
- return value;
32
- }
33
-
34
- function createTwilioRequestDedupeKey(ctx: WebhookContext): string {
35
- const idempotencyToken = getHeader(ctx.headers, "i-twilio-idempotency-token");
36
- if (idempotencyToken) {
37
- return `twilio:idempotency:${idempotencyToken}`;
25
+ function createTwilioRequestDedupeKey(ctx: WebhookContext, verifiedRequestKey?: string): string {
26
+ if (verifiedRequestKey) {
27
+ return verifiedRequestKey;
38
28
  }
39
29
 
40
30
  const signature = getHeader(ctx.headers, "x-twilio-signature") ?? "";
31
+ const params = new URLSearchParams(ctx.rawBody);
32
+ const callSid = params.get("CallSid") ?? "";
33
+ const callStatus = params.get("CallStatus") ?? "";
34
+ const direction = params.get("Direction") ?? "";
41
35
  const callId = typeof ctx.query?.callId === "string" ? ctx.query.callId.trim() : "";
42
36
  const flow = typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : "";
43
37
  const turnToken = typeof ctx.query?.turnToken === "string" ? ctx.query.turnToken.trim() : "";
44
38
  return `twilio:fallback:${crypto
45
39
  .createHash("sha256")
46
- .update(`${signature}\n${callId}\n${flow}\n${turnToken}\n${ctx.rawBody}`)
40
+ .update(
41
+ `${signature}\n${callSid}\n${callStatus}\n${direction}\n${callId}\n${flow}\n${turnToken}\n${ctx.rawBody}`,
42
+ )
47
43
  .digest("hex")}`;
48
44
  }
49
45
 
@@ -232,7 +228,10 @@ export class TwilioProvider implements VoiceCallProvider {
232
228
  /**
233
229
  * Parse Twilio webhook event into normalized format.
234
230
  */
235
- parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult {
231
+ parseWebhookEvent(
232
+ ctx: WebhookContext,
233
+ options?: WebhookParseOptions,
234
+ ): ProviderWebhookParseResult {
236
235
  try {
237
236
  const params = new URLSearchParams(ctx.rawBody);
238
237
  const callIdFromQuery =
@@ -243,7 +242,7 @@ export class TwilioProvider implements VoiceCallProvider {
243
242
  typeof ctx.query?.turnToken === "string" && ctx.query.turnToken.trim()
244
243
  ? ctx.query.turnToken.trim()
245
244
  : undefined;
246
- const dedupeKey = createTwilioRequestDedupeKey(ctx);
245
+ const dedupeKey = createTwilioRequestDedupeKey(ctx, options?.verifiedRequestKey);
247
246
  const event = this.normalizeEvent(params, {
248
247
  callIdOverride: callIdFromQuery,
249
248
  dedupeKey,
package/src/types.ts CHANGED
@@ -177,6 +177,13 @@ export type WebhookVerificationResult = {
177
177
  reason?: string;
178
178
  /** Signature is valid, but request was seen before within replay window. */
179
179
  isReplay?: boolean;
180
+ /** Stable key derived from authenticated request material. */
181
+ verifiedRequestKey?: string;
182
+ };
183
+
184
+ export type WebhookParseOptions = {
185
+ /** Stable request key from verifyWebhook. */
186
+ verifiedRequestKey?: string;
180
187
  };
181
188
 
182
189
  export type WebhookContext = {
@@ -0,0 +1,33 @@
1
+ import type { CallManager } from "../manager.js";
2
+
3
+ const CHECK_INTERVAL_MS = 30_000;
4
+
5
+ export function startStaleCallReaper(params: {
6
+ manager: CallManager;
7
+ staleCallReaperSeconds?: number;
8
+ }): (() => void) | null {
9
+ const maxAgeSeconds = params.staleCallReaperSeconds;
10
+ if (!maxAgeSeconds || maxAgeSeconds <= 0) {
11
+ return null;
12
+ }
13
+
14
+ const maxAgeMs = maxAgeSeconds * 1000;
15
+ const interval = setInterval(() => {
16
+ const now = Date.now();
17
+ for (const call of params.manager.getActiveCalls()) {
18
+ const age = now - call.startedAt;
19
+ if (age > maxAgeMs) {
20
+ console.log(
21
+ `[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1000)}s, state: ${call.state})`,
22
+ );
23
+ void params.manager.endCall(call.callId).catch((err) => {
24
+ console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
25
+ });
26
+ }
27
+ }
28
+ }, CHECK_INTERVAL_MS);
29
+
30
+ return () => {
31
+ clearInterval(interval);
32
+ };
33
+ }
@@ -198,8 +198,26 @@ describe("verifyPlivoWebhook", () => {
198
198
 
199
199
  expect(first.ok).toBe(true);
200
200
  expect(first.isReplay).toBeFalsy();
201
+ expect(first.verifiedRequestKey).toBeTruthy();
201
202
  expect(second.ok).toBe(true);
202
203
  expect(second.isReplay).toBe(true);
204
+ expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
205
+ });
206
+
207
+ it("returns a stable request key when verification is skipped", () => {
208
+ const ctx = {
209
+ headers: {},
210
+ rawBody: "CallUUID=uuid&CallStatus=in-progress",
211
+ url: "https://example.com/voice/webhook",
212
+ method: "POST" as const,
213
+ };
214
+ const first = verifyPlivoWebhook(ctx, "token", { skipVerification: true });
215
+ const second = verifyPlivoWebhook(ctx, "token", { skipVerification: true });
216
+
217
+ expect(first.ok).toBe(true);
218
+ expect(first.verifiedRequestKey).toMatch(/^plivo:skip:/);
219
+ expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
220
+ expect(second.isReplay).toBe(true);
203
221
  });
204
222
  });
205
223
 
@@ -229,8 +247,26 @@ describe("verifyTelnyxWebhook", () => {
229
247
 
230
248
  expect(first.ok).toBe(true);
231
249
  expect(first.isReplay).toBeFalsy();
250
+ expect(first.verifiedRequestKey).toBeTruthy();
232
251
  expect(second.ok).toBe(true);
233
252
  expect(second.isReplay).toBe(true);
253
+ expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
254
+ });
255
+
256
+ it("returns a stable request key when verification is skipped", () => {
257
+ const ctx = {
258
+ headers: {},
259
+ rawBody: JSON.stringify({ data: { event_type: "call.initiated" } }),
260
+ url: "https://example.com/voice/webhook",
261
+ method: "POST" as const,
262
+ };
263
+ const first = verifyTelnyxWebhook(ctx, undefined, { skipVerification: true });
264
+ const second = verifyTelnyxWebhook(ctx, undefined, { skipVerification: true });
265
+
266
+ expect(first.ok).toBe(true);
267
+ expect(first.verifiedRequestKey).toMatch(/^telnyx:skip:/);
268
+ expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
269
+ expect(second.isReplay).toBe(true);
234
270
  });
235
271
  });
236
272
 
@@ -304,8 +340,58 @@ describe("verifyTwilioWebhook", () => {
304
340
 
305
341
  expect(first.ok).toBe(true);
306
342
  expect(first.isReplay).toBeFalsy();
343
+ expect(first.verifiedRequestKey).toBeTruthy();
344
+ expect(second.ok).toBe(true);
345
+ expect(second.isReplay).toBe(true);
346
+ expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
347
+ });
348
+
349
+ it("treats changed idempotency header as replay for identical signed requests", () => {
350
+ const authToken = "test-auth-token";
351
+ const publicUrl = "https://example.com/voice/webhook";
352
+ const urlWithQuery = `${publicUrl}?callId=abc`;
353
+ const postBody = "CallSid=CS778&CallStatus=completed&From=%2B15550000000";
354
+ const signature = twilioSignature({ authToken, url: urlWithQuery, postBody });
355
+
356
+ const first = verifyTwilioWebhook(
357
+ {
358
+ headers: {
359
+ host: "example.com",
360
+ "x-forwarded-proto": "https",
361
+ "x-twilio-signature": signature,
362
+ "i-twilio-idempotency-token": "idem-replay-a",
363
+ },
364
+ rawBody: postBody,
365
+ url: "http://local/voice/webhook?callId=abc",
366
+ method: "POST",
367
+ query: { callId: "abc" },
368
+ },
369
+ authToken,
370
+ { publicUrl },
371
+ );
372
+ const second = verifyTwilioWebhook(
373
+ {
374
+ headers: {
375
+ host: "example.com",
376
+ "x-forwarded-proto": "https",
377
+ "x-twilio-signature": signature,
378
+ "i-twilio-idempotency-token": "idem-replay-b",
379
+ },
380
+ rawBody: postBody,
381
+ url: "http://local/voice/webhook?callId=abc",
382
+ method: "POST",
383
+ query: { callId: "abc" },
384
+ },
385
+ authToken,
386
+ { publicUrl },
387
+ );
388
+
389
+ expect(first.ok).toBe(true);
390
+ expect(first.isReplay).toBe(false);
391
+ expect(first.verifiedRequestKey).toBeTruthy();
307
392
  expect(second.ok).toBe(true);
308
393
  expect(second.isReplay).toBe(true);
394
+ expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
309
395
  });
310
396
 
311
397
  it("rejects invalid signatures even when attacker injects forwarded host", () => {
@@ -517,4 +603,20 @@ describe("verifyTwilioWebhook", () => {
517
603
  expect(result.ok).toBe(false);
518
604
  expect(result.verificationUrl).toBe("https://legitimate.example.com/voice/webhook");
519
605
  });
606
+
607
+ it("returns a stable request key when verification is skipped", () => {
608
+ const ctx = {
609
+ headers: {},
610
+ rawBody: "CallSid=CS123&CallStatus=completed",
611
+ url: "https://example.com/voice/webhook",
612
+ method: "POST" as const,
613
+ };
614
+ const first = verifyTwilioWebhook(ctx, "token", { skipVerification: true });
615
+ const second = verifyTwilioWebhook(ctx, "token", { skipVerification: true });
616
+
617
+ expect(first.ok).toBe(true);
618
+ expect(first.verifiedRequestKey).toMatch(/^twilio:skip:/);
619
+ expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
620
+ expect(second.isReplay).toBe(true);
621
+ });
520
622
  });
@@ -1,4 +1,5 @@
1
1
  import crypto from "node:crypto";
2
+ import { getHeader } from "./http-headers.js";
2
3
  import type { WebhookContext } from "./types.js";
3
4
 
4
5
  const REPLAY_WINDOW_MS = 10 * 60 * 1000;
@@ -29,6 +30,10 @@ function sha256Hex(input: string): string {
29
30
  return crypto.createHash("sha256").update(input).digest("hex");
30
31
  }
31
32
 
33
+ function createSkippedVerificationReplayKey(provider: string, ctx: WebhookContext): string {
34
+ return `${provider}:skip:${sha256Hex(`${ctx.method}\n${ctx.url}\n${ctx.rawBody}`)}`;
35
+ }
36
+
32
37
  function pruneReplayCache(cache: ReplayCache, now: number): void {
33
38
  for (const [key, expiresAt] of cache.seenUntil) {
34
39
  if (expiresAt <= now) {
@@ -81,17 +86,7 @@ export function validateTwilioSignature(
81
86
  return false;
82
87
  }
83
88
 
84
- // Build the string to sign: URL + sorted params (key+value pairs)
85
- let dataToSign = url;
86
-
87
- // Sort params alphabetically and append key+value
88
- const sortedParams = Array.from(params.entries()).toSorted((a, b) =>
89
- a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,
90
- );
91
-
92
- for (const [key, value] of sortedParams) {
93
- dataToSign += key + value;
94
- }
89
+ const dataToSign = buildTwilioDataToSign(url, params);
95
90
 
96
91
  // HMAC-SHA1 with auth token, then base64 encode
97
92
  const expectedSignature = crypto
@@ -103,6 +98,24 @@ export function validateTwilioSignature(
103
98
  return timingSafeEqual(signature, expectedSignature);
104
99
  }
105
100
 
101
+ function buildTwilioDataToSign(url: string, params: URLSearchParams): string {
102
+ let dataToSign = url;
103
+ const sortedParams = Array.from(params.entries()).toSorted((a, b) =>
104
+ a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,
105
+ );
106
+ for (const [key, value] of sortedParams) {
107
+ dataToSign += key + value;
108
+ }
109
+ return dataToSign;
110
+ }
111
+
112
+ function buildCanonicalTwilioParamString(params: URLSearchParams): string {
113
+ return Array.from(params.entries())
114
+ .toSorted((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
115
+ .map(([key, value]) => `${key}=${value}`)
116
+ .join("&");
117
+ }
118
+
106
119
  /**
107
120
  * Timing-safe string comparison to prevent timing attacks.
108
121
  */
@@ -353,20 +366,6 @@ function buildTwilioVerificationUrl(
353
366
  }
354
367
  }
355
368
 
356
- /**
357
- * Get a header value, handling both string and string[] types.
358
- */
359
- function getHeader(
360
- headers: Record<string, string | string[] | undefined>,
361
- name: string,
362
- ): string | undefined {
363
- const value = headers[name.toLowerCase()];
364
- if (Array.isArray(value)) {
365
- return value[0];
366
- }
367
- return value;
368
- }
369
-
370
369
  function isLoopbackAddress(address?: string): boolean {
371
370
  if (!address) {
372
371
  return false;
@@ -392,6 +391,8 @@ export interface TwilioVerificationResult {
392
391
  isNgrokFreeTier?: boolean;
393
392
  /** Request is cryptographically valid but was already processed recently. */
394
393
  isReplay?: boolean;
394
+ /** Stable request identity derived from signed Twilio material. */
395
+ verifiedRequestKey?: string;
395
396
  }
396
397
 
397
398
  export interface TelnyxVerificationResult {
@@ -399,19 +400,18 @@ export interface TelnyxVerificationResult {
399
400
  reason?: string;
400
401
  /** Request is cryptographically valid but was already processed recently. */
401
402
  isReplay?: boolean;
403
+ /** Stable request identity derived from signed Telnyx material. */
404
+ verifiedRequestKey?: string;
402
405
  }
403
406
 
404
407
  function createTwilioReplayKey(params: {
405
- ctx: WebhookContext;
406
- signature: string;
407
408
  verificationUrl: string;
409
+ signature: string;
410
+ requestParams: URLSearchParams;
408
411
  }): string {
409
- const idempotencyToken = getHeader(params.ctx.headers, "i-twilio-idempotency-token");
410
- if (idempotencyToken) {
411
- return `twilio:idempotency:${idempotencyToken}`;
412
- }
413
- return `twilio:fallback:${sha256Hex(
414
- `${params.verificationUrl}\n${params.signature}\n${params.ctx.rawBody}`,
412
+ const canonicalParams = buildCanonicalTwilioParamString(params.requestParams);
413
+ return `twilio:req:${sha256Hex(
414
+ `${params.verificationUrl}\n${canonicalParams}\n${params.signature}`,
415
415
  )}`;
416
416
  }
417
417
 
@@ -470,7 +470,14 @@ export function verifyTelnyxWebhook(
470
470
  },
471
471
  ): TelnyxVerificationResult {
472
472
  if (options?.skipVerification) {
473
- return { ok: true, reason: "verification skipped (dev mode)" };
473
+ const replayKey = createSkippedVerificationReplayKey("telnyx", ctx);
474
+ const isReplay = markReplay(telnyxReplayCache, replayKey);
475
+ return {
476
+ ok: true,
477
+ reason: "verification skipped (dev mode)",
478
+ isReplay,
479
+ verifiedRequestKey: replayKey,
480
+ };
474
481
  }
475
482
 
476
483
  if (!publicKey) {
@@ -508,7 +515,7 @@ export function verifyTelnyxWebhook(
508
515
 
509
516
  const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${signature}\n${ctx.rawBody}`)}`;
510
517
  const isReplay = markReplay(telnyxReplayCache, replayKey);
511
- return { ok: true, isReplay };
518
+ return { ok: true, isReplay, verifiedRequestKey: replayKey };
512
519
  } catch (err) {
513
520
  return {
514
521
  ok: false,
@@ -560,7 +567,14 @@ export function verifyTwilioWebhook(
560
567
  ): TwilioVerificationResult {
561
568
  // Allow skipping verification for development/testing
562
569
  if (options?.skipVerification) {
563
- return { ok: true, reason: "verification skipped (dev mode)" };
570
+ const replayKey = createSkippedVerificationReplayKey("twilio", ctx);
571
+ const isReplay = markReplay(twilioReplayCache, replayKey);
572
+ return {
573
+ ok: true,
574
+ reason: "verification skipped (dev mode)",
575
+ isReplay,
576
+ verifiedRequestKey: replayKey,
577
+ };
564
578
  }
565
579
 
566
580
  const signature = getHeader(ctx.headers, "x-twilio-signature");
@@ -583,13 +597,16 @@ export function verifyTwilioWebhook(
583
597
  // Parse the body as URL-encoded params
584
598
  const params = new URLSearchParams(ctx.rawBody);
585
599
 
586
- // Validate signature
587
600
  const isValid = validateTwilioSignature(authToken, signature, verificationUrl, params);
588
601
 
589
602
  if (isValid) {
590
- const replayKey = createTwilioReplayKey({ ctx, signature, verificationUrl });
603
+ const replayKey = createTwilioReplayKey({
604
+ verificationUrl,
605
+ signature,
606
+ requestParams: params,
607
+ });
591
608
  const isReplay = markReplay(twilioReplayCache, replayKey);
592
- return { ok: true, verificationUrl, isReplay };
609
+ return { ok: true, verificationUrl, isReplay, verifiedRequestKey: replayKey };
593
610
  }
594
611
 
595
612
  // Check if this is ngrok free tier - the URL might have different format
@@ -619,6 +636,8 @@ export interface PlivoVerificationResult {
619
636
  version?: "v3" | "v2";
620
637
  /** Request is cryptographically valid but was already processed recently. */
621
638
  isReplay?: boolean;
639
+ /** Stable request identity derived from signed Plivo material. */
640
+ verifiedRequestKey?: string;
622
641
  }
623
642
 
624
643
  function normalizeSignatureBase64(input: string): string {
@@ -791,7 +810,14 @@ export function verifyPlivoWebhook(
791
810
  },
792
811
  ): PlivoVerificationResult {
793
812
  if (options?.skipVerification) {
794
- return { ok: true, reason: "verification skipped (dev mode)" };
813
+ const replayKey = createSkippedVerificationReplayKey("plivo", ctx);
814
+ const isReplay = markReplay(plivoReplayCache, replayKey);
815
+ return {
816
+ ok: true,
817
+ reason: "verification skipped (dev mode)",
818
+ isReplay,
819
+ verifiedRequestKey: replayKey,
820
+ };
795
821
  }
796
822
 
797
823
  const signatureV3 = getHeader(ctx.headers, "x-plivo-signature-v3");
@@ -849,7 +875,7 @@ export function verifyPlivoWebhook(
849
875
  }
850
876
  const replayKey = `plivo:v3:${sha256Hex(`${verificationUrl}\n${nonceV3}`)}`;
851
877
  const isReplay = markReplay(plivoReplayCache, replayKey);
852
- return { ok: true, version: "v3", verificationUrl, isReplay };
878
+ return { ok: true, version: "v3", verificationUrl, isReplay, verifiedRequestKey: replayKey };
853
879
  }
854
880
 
855
881
  if (signatureV2 && nonceV2) {
@@ -869,7 +895,7 @@ export function verifyPlivoWebhook(
869
895
  }
870
896
  const replayKey = `plivo:v2:${sha256Hex(`${verificationUrl}\n${nonceV2}`)}`;
871
897
  const isReplay = markReplay(plivoReplayCache, replayKey);
872
- return { ok: true, version: "v2", verificationUrl, isReplay };
898
+ return { ok: true, version: "v2", verificationUrl, isReplay, verifiedRequestKey: replayKey };
873
899
  }
874
900
 
875
901
  return {
@@ -7,7 +7,7 @@ import { VoiceCallWebhookServer } from "./webhook.js";
7
7
 
8
8
  const provider: VoiceCallProvider = {
9
9
  name: "mock",
10
- verifyWebhook: () => ({ ok: true }),
10
+ verifyWebhook: () => ({ ok: true, verifiedRequestKey: "mock:req:base" }),
11
11
  parseWebhookEvent: () => ({ events: [] }),
12
12
  initiateCall: async () => ({ providerCallId: "provider-call", status: "initiated" }),
13
13
  hangupCall: async () => {},
@@ -123,7 +123,7 @@ describe("VoiceCallWebhookServer replay handling", () => {
123
123
  it("acknowledges replayed webhook requests and skips event side effects", async () => {
124
124
  const replayProvider: VoiceCallProvider = {
125
125
  ...provider,
126
- verifyWebhook: () => ({ ok: true, isReplay: true }),
126
+ verifyWebhook: () => ({ ok: true, isReplay: true, verifiedRequestKey: "mock:req:replay" }),
127
127
  parseWebhookEvent: () => ({
128
128
  events: [
129
129
  {
@@ -165,4 +165,89 @@ describe("VoiceCallWebhookServer replay handling", () => {
165
165
  await server.stop();
166
166
  }
167
167
  });
168
+
169
+ it("passes verified request key from verifyWebhook into parseWebhookEvent", async () => {
170
+ const parseWebhookEvent = vi.fn((_ctx: unknown, options?: { verifiedRequestKey?: string }) => ({
171
+ events: [
172
+ {
173
+ id: "evt-verified",
174
+ dedupeKey: options?.verifiedRequestKey,
175
+ type: "call.speech" as const,
176
+ callId: "call-1",
177
+ providerCallId: "provider-call-1",
178
+ timestamp: Date.now(),
179
+ transcript: "hello",
180
+ isFinal: true,
181
+ },
182
+ ],
183
+ statusCode: 200,
184
+ }));
185
+ const verifiedProvider: VoiceCallProvider = {
186
+ ...provider,
187
+ verifyWebhook: () => ({ ok: true, verifiedRequestKey: "verified:req:123" }),
188
+ parseWebhookEvent,
189
+ };
190
+ const { manager, processEvent } = createManager([]);
191
+ const config = createConfig({ serve: { port: 0, bind: "127.0.0.1", path: "/voice/webhook" } });
192
+ const server = new VoiceCallWebhookServer(config, manager, verifiedProvider);
193
+
194
+ try {
195
+ const baseUrl = await server.start();
196
+ const address = (
197
+ server as unknown as { server?: { address?: () => unknown } }
198
+ ).server?.address?.();
199
+ const requestUrl = new URL(baseUrl);
200
+ if (address && typeof address === "object" && "port" in address && address.port) {
201
+ requestUrl.port = String(address.port);
202
+ }
203
+ const response = await fetch(requestUrl.toString(), {
204
+ method: "POST",
205
+ headers: { "content-type": "application/x-www-form-urlencoded" },
206
+ body: "CallSid=CA123&SpeechResult=hello",
207
+ });
208
+
209
+ expect(response.status).toBe(200);
210
+ expect(parseWebhookEvent).toHaveBeenCalledTimes(1);
211
+ expect(parseWebhookEvent.mock.calls[0]?.[1]).toEqual({
212
+ verifiedRequestKey: "verified:req:123",
213
+ });
214
+ expect(processEvent).toHaveBeenCalledTimes(1);
215
+ expect(processEvent.mock.calls[0]?.[0]?.dedupeKey).toBe("verified:req:123");
216
+ } finally {
217
+ await server.stop();
218
+ }
219
+ });
220
+
221
+ it("rejects requests when verification succeeds without a request key", async () => {
222
+ const parseWebhookEvent = vi.fn(() => ({ events: [], statusCode: 200 }));
223
+ const badProvider: VoiceCallProvider = {
224
+ ...provider,
225
+ verifyWebhook: () => ({ ok: true }),
226
+ parseWebhookEvent,
227
+ };
228
+ const { manager } = createManager([]);
229
+ const config = createConfig({ serve: { port: 0, bind: "127.0.0.1", path: "/voice/webhook" } });
230
+ const server = new VoiceCallWebhookServer(config, manager, badProvider);
231
+
232
+ try {
233
+ const baseUrl = await server.start();
234
+ const address = (
235
+ server as unknown as { server?: { address?: () => unknown } }
236
+ ).server?.address?.();
237
+ const requestUrl = new URL(baseUrl);
238
+ if (address && typeof address === "object" && "port" in address && address.port) {
239
+ requestUrl.port = String(address.port);
240
+ }
241
+ const response = await fetch(requestUrl.toString(), {
242
+ method: "POST",
243
+ headers: { "content-type": "application/x-www-form-urlencoded" },
244
+ body: "CallSid=CA123&SpeechResult=hello",
245
+ });
246
+
247
+ expect(response.status).toBe(401);
248
+ expect(parseWebhookEvent).not.toHaveBeenCalled();
249
+ } finally {
250
+ await server.stop();
251
+ }
252
+ });
168
253
  });
package/src/webhook.ts CHANGED
@@ -15,6 +15,7 @@ import type { VoiceCallProvider } from "./providers/base.js";
15
15
  import { OpenAIRealtimeSTTProvider } from "./providers/stt-openai-realtime.js";
16
16
  import type { TwilioProvider } from "./providers/twilio.js";
17
17
  import type { NormalizedEvent, WebhookContext } from "./types.js";
18
+ import { startStaleCallReaper } from "./webhook/stale-call-reaper.js";
18
19
 
19
20
  const MAX_WEBHOOK_BODY_BYTES = 1024 * 1024;
20
21
 
@@ -28,7 +29,7 @@ export class VoiceCallWebhookServer {
28
29
  private manager: CallManager;
29
30
  private provider: VoiceCallProvider;
30
31
  private coreConfig: CoreConfig | null;
31
- private staleCallReaperInterval: ReturnType<typeof setInterval> | null = null;
32
+ private stopStaleCallReaper: (() => void) | null = null;
32
33
 
33
34
  /** Media stream handler for bidirectional audio (when streaming enabled) */
34
35
  private mediaStreamHandler: MediaStreamHandler | null = null;
@@ -217,48 +218,21 @@ export class VoiceCallWebhookServer {
217
218
  resolve(url);
218
219
 
219
220
  // Start the stale call reaper if configured
220
- this.startStaleCallReaper();
221
+ this.stopStaleCallReaper = startStaleCallReaper({
222
+ manager: this.manager,
223
+ staleCallReaperSeconds: this.config.staleCallReaperSeconds,
224
+ });
221
225
  });
222
226
  });
223
227
  }
224
228
 
225
- /**
226
- * Start a periodic reaper that ends calls older than the configured threshold.
227
- * Catches calls stuck in unexpected states (e.g., notify-mode calls that never
228
- * receive a terminal webhook from the provider).
229
- */
230
- private startStaleCallReaper(): void {
231
- const maxAgeSeconds = this.config.staleCallReaperSeconds;
232
- if (!maxAgeSeconds || maxAgeSeconds <= 0) {
233
- return;
234
- }
235
-
236
- const CHECK_INTERVAL_MS = 30_000; // Check every 30 seconds
237
- const maxAgeMs = maxAgeSeconds * 1000;
238
-
239
- this.staleCallReaperInterval = setInterval(() => {
240
- const now = Date.now();
241
- for (const call of this.manager.getActiveCalls()) {
242
- const age = now - call.startedAt;
243
- if (age > maxAgeMs) {
244
- console.log(
245
- `[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1000)}s, state: ${call.state})`,
246
- );
247
- void this.manager.endCall(call.callId).catch((err) => {
248
- console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
249
- });
250
- }
251
- }
252
- }, CHECK_INTERVAL_MS);
253
- }
254
-
255
229
  /**
256
230
  * Stop the webhook server.
257
231
  */
258
232
  async stop(): Promise<void> {
259
- if (this.staleCallReaperInterval) {
260
- clearInterval(this.staleCallReaperInterval);
261
- this.staleCallReaperInterval = null;
233
+ if (this.stopStaleCallReaper) {
234
+ this.stopStaleCallReaper();
235
+ this.stopStaleCallReaper = null;
262
236
  }
263
237
  return new Promise((resolve) => {
264
238
  if (this.server) {
@@ -341,9 +315,17 @@ export class VoiceCallWebhookServer {
341
315
  res.end("Unauthorized");
342
316
  return;
343
317
  }
318
+ if (!verification.verifiedRequestKey) {
319
+ console.warn("[voice-call] Webhook verification succeeded without request identity key");
320
+ res.statusCode = 401;
321
+ res.end("Unauthorized");
322
+ return;
323
+ }
344
324
 
345
325
  // Parse events
346
- const result = this.provider.parseWebhookEvent(ctx);
326
+ const result = this.provider.parseWebhookEvent(ctx, {
327
+ verifiedRequestKey: verification.verifiedRequestKey,
328
+ });
347
329
 
348
330
  // Process each event
349
331
  if (verification.isReplay) {