@signalhousellc/sdk 1.0.63 → 1.0.65

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalhousellc/sdk",
3
- "version": "1.0.63",
3
+ "version": "1.0.65",
4
4
  "description": "Signal House SDK for use with the Signal House platform",
5
5
  "type": "module",
6
6
  "main": "src/SignalHouseSDK.js",
@@ -2,6 +2,28 @@ export class Auth {
2
2
  constructor(client, enableAdmin) {
3
3
  this.client = client;
4
4
  this.enableAdmin = enableAdmin;
5
+
6
+ // Hidden Admin namespace INSIDE the domain
7
+ if (enableAdmin) {
8
+ this.admin = {
9
+ /**
10
+ * Force-log-out every human user in a specific group (SHGHL-2740) - for account-takeover
11
+ * response, independent of the automatic Rejected-lockout trigger.
12
+ * @async
13
+ * @roles signalhouse
14
+ * @param {Object} params - The parameters for forcing a group logout
15
+ * @param {string} params.groupId - The ID of the group whose users should be force-logged-out
16
+ * @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
17
+ * @throws {Error} Throws an error if the groupId parameter is missing
18
+ * @returns {Promise<Object>} - The response containing loggedOutCount
19
+ */
20
+ forceLogoutGroupUsers: async ({ groupId, options = {} }) => {
21
+ this.client._require({ groupId });
22
+ const safeGroupId = encodeURIComponent(groupId);
23
+ return this.client(`/auth/groups/${safeGroupId}/logout-all`, { method: "POST", ...options });
24
+ },
25
+ };
26
+ }
5
27
  }
6
28
 
7
29
  /**
@@ -47,13 +69,22 @@ export class Auth {
47
69
  }
48
70
 
49
71
  /**
50
- * Verify an account email using the single-use token from the verification link
72
+ * Verify an account email using the single-use token from the verification link, signing the
73
+ * account in on success.
74
+ *
75
+ * The response carries a session JWT (`token`) for the user the link was emailed to, so a click
76
+ * from any device establishes a session — use it exactly as you would the token from `login`.
77
+ * `token` is omitted when the link cannot establish a session (issued before auto-login shipped,
78
+ * or the account is inactive/ineligible); `verified` is still true in that case, so treat a
79
+ * missing token as "verified, ask them to log in" rather than as a failure.
80
+ *
81
+ * Rate limited per source IP. The token is single-use and expires 24 hours after it is issued.
51
82
  * @async
52
83
  * @roles public
53
84
  * @param {Object} params
54
85
  * @param {string} params.token - The verification token from the email link
55
86
  * @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
56
- * @returns {Promise<Object>} The response from the server ({ verified: true })
87
+ * @returns {Promise<Object>} The response from the server ({ verified: true, token?: string })
57
88
  */
58
89
  async verifyEmail({ token, options = {} }) {
59
90
  this.client._require({ token });
@@ -92,7 +123,9 @@ export class Auth {
92
123
  }
93
124
 
94
125
  /**
95
- * Resend the account verification email to the authenticated caller
126
+ * Resend the account verification email to the authenticated caller.
127
+ *
128
+ * Rate limited per user; issuing a new link invalidates the previous one.
96
129
  * @async
97
130
  * @roles admin, developer, billing, user
98
131
  * @param {Object} [params]
@@ -30,7 +30,7 @@
30
30
  * @property {string} vertical - The vertical for the brand (PROFESSIONAL, REAL_ESTATE, HEALTHCARE, HUMAN_RESOURCES, ENERGY, ENTERTAINMENT, RETAIL, TRANSPORTATION, AGRICULTURE, INSURANCE, POSTAL, EDUCATION, HOSPITALITY, FINANCIAL, POLITICAL, GAMBLING, LEGAL, CONSTRUCTION, NGO, MANUFACTURING, GOVERNMENT, TECHNOLOGY, COMMUNICATION)
31
31
  * @property {string} [referenceId] - A unique reference for the brand
32
32
  * @property {Array<string>} [tag] - An array of tags to associate with the brand
33
- * @property {boolean} [mock] - Whether to create the brand in the mock environment (for testing purposes only)
33
+ * @property {boolean} [mock] - Whether to register the brand in TCR's mock environment instead of the live registry. TCR is still called and the mock brand auto-approves, so it does not exercise the rejection states a live registration can hit. Brand creation is not billed; campaign registration and number purchase are still billed as normal
34
34
  * @property {string} [businessContactEmail] - The email address of the business contact for the brand (required if entityType is PUBLIC_PROFIT or NON_PROFIT)
35
35
  */
36
36
 
@@ -59,7 +59,7 @@
59
59
  * @property {string} [altBusinessIdType] - The type of the alternative business identifier ("NONE", "DUNS", "LEI", "GIIN")
60
60
  * @property {string} [brandRelationship] - Not in use - reserved for future use
61
61
  * @property {string} [businessContactEmail] - The email address of the business contact for the brand (required if entityType is PUBLIC_PROFIT or NON_PROFIT)
62
- * @property {Object} [tollFree] - Toll-Free editable fields, used when updating a Toll-Free brand. Optional sub-fields: legalEntityType, businessRegistrationType, taxId, countryCode, supportPhone, taxIdIssuingCountry, businessDBA, businessWebsite. (Toll-Free brands sync to Infobip; only DCA_PENDING/DCA_ACTIVE brands are editable and subgroupId is immutable.)
62
+ * @property {Object} [tollFree] - Toll-Free editable fields, used when updating a Toll-Free brand. Optional sub-fields: legalEntityType, businessRegistrationType, taxId, countryCode, supportPhone, taxIdIssuingCountry, businessDBA, businessWebsite. supportPhone must be E.164 with an optional leading "+" and no spaces, parentheses or hyphens (e.g. "17025550100"). (Toll-Free brands sync to Infobip; only DCA_PENDING/DCA_ACTIVE brands are editable and subgroupId is immutable.)
63
63
  */
64
64
 
65
65
  /**
@@ -68,7 +68,7 @@
68
68
  * @property {string} legalEntityType - The legal entity type (required, one of PRIVATE_COMPANY, PUBLIC_COMPANY, NON_PROFIT, GOVERNMENT, SOLE_PROPRIETOR)
69
69
  * @property {string} taxId - The business tax identifier (required, at most 50 characters)
70
70
  * @property {string} countryCode - The business country code (required, 2-letter ISO code)
71
- * @property {string} supportPhone - The customer-support phone number (required, at most 20 characters)
71
+ * @property {string} supportPhone - The customer-support phone number (required, E.164 with an optional leading "+" and no spaces, parentheses or hyphens, e.g. "17025550100")
72
72
  * @property {string} [taxIdIssuingCountry] - The country that issued the tax ID (optional, 2-letter ISO code)
73
73
  * @property {string} [businessDBA] - The business "doing business as" name (optional, at most 255 characters)
74
74
  * @property {string} [businessWebsite] - The customer's real website, sent to Infobip for brand verification (optional, valid URL, at most 255 characters; distinct from the top-level website / landing URL)
@@ -90,7 +90,7 @@
90
90
  * @property {TollFreeBrandDetails} tollFree - The Toll-Free brand details (required)
91
91
  * @property {string} [referenceId] - A unique reference for the brand (optional, at most 50 characters)
92
92
  * @property {Array<string>} [tag] - An array of tags to associate with the brand (optional)
93
- * @property {boolean} [mock] - Whether to create the brand in the mock environment (for testing purposes only)
93
+ * @property {boolean} [mock] - Accepted and stored, but has no effect on Toll-Free registration: Toll-Free does not use TCR and is already free
94
94
  * @property {string} [optInLink] - A URL linking to the opt-in flow for the brand (optional, valid URL, at most 255 characters)
95
95
  * @property {string} [privacyPolicyLink] - A URL linking to the privacy policy for the brand (optional, valid URL, at most 255 characters)
96
96
  * @property {string} [termsAndConditionsLink] - A URL linking to the terms and conditions for the brand (optional, valid URL, at most 255 characters)
@@ -6,6 +6,23 @@ export class Groups {
6
6
  // Hidden Admin namespace INSIDE the domain
7
7
  if (enableAdmin) {
8
8
  this.admin = {
9
+ /**
10
+ * Get resource counts grouped by group ID. Only signalhouse_admin can query all groups;
11
+ * every other allowed role is restricted to its JWT group scope.
12
+ * @async
13
+ * @roles signalhouse, api, admin, developer, billing, user
14
+ * @param {Object} [params] - Optional group-count filters and pagination.
15
+ * @param {string} [params.groupId] - Restrict results to a group.
16
+ * @param {number} [params.limit] - Maximum rows to return.
17
+ * @param {number} [params.offset] - Rows to skip; requires limit.
18
+ * @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
19
+ * @returns {Promise<Object>} - Group-level resource count rows.
20
+ */
21
+ getGroupCounts: async ({ groupId, limit, offset, options = {} } = {}) => {
22
+ const queryString = this.client._getQueryString({ groupId, limit, offset });
23
+ return this.client(`/group/counts${queryString}`, { method: "GET", ...options });
24
+ },
25
+
9
26
  /**
10
27
  * Get a list of all groups with optional pagination parameters
11
28
  * @async
@@ -27,7 +44,7 @@ export class Groups {
27
44
  * @async
28
45
  * @roles signalhouse
29
46
  * @param {Object} params - The parameters for creating a new group
30
- * @param {Object} params.groupData - The data for the new group, including required fields such as groupName
47
+ * @param {Object} params.groupData - The data for the new group, including required fields such as groupName. Optionally accepts `psPartnerKey` and `ownerEmail` to credit a referring partner.
31
48
  * @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
32
49
  * @throws {Error} Throws an error if the groupData parameter is missing or if required fields within groupData are missing
33
50
  * @returns {Promise<Object>} - The created group object returned from the server
@@ -18,10 +18,10 @@ export class Messages {
18
18
  * @param {string} [params.status] - Filter messages by their status (ENQUEUED, DEQUEUED, SENT, FAILED, DELIVERED)
19
19
  * @param {string} [params.direction] - Filter messages by their direction (INBOUND, OUTBOUND)
20
20
  * @param {string|string[]} [params.messageType] - Filter messages by their type. Accepts a single value or an array (e.g. ["SMS", "MMS"]). Allowed values: SMS, MMS, RCS, WHATSAPP, VIBER, P2P. When omitted, defaults to all non-P2P types.
21
- * @param {string|string[]} [params.channel] - Filter messages by channel. Accepts a single value or an array. Allowed values: tenDLC, tollFree, p2p. Each message records its channel when it is saved, and this filters on that stored value; older messages from before this feature have no channel and count as tenDLC. Ignored when matchAny is true (matchAny takes precedence).
21
+ * @param {string|string[]} [params.channel] - Filter messages by channel. Accepts a single value or an array. Allowed values: tenDLC, tollFree, shortCode, p2p. Each message records its channel when it is saved, and this filters on that stored value; older messages from before this feature have no channel and count as tenDLC. Ignored when matchAny is true (matchAny takes precedence).
22
22
  * @param {boolean} [params.matchAny] - When true, the provided campaignId/brandId/phoneNumber filters are OR'd (a message matches ANY of them) instead of the default most-specific-single behavior. subgroupId stays an AND scope. Opt-in/additive.
23
23
  * @param {string} [params.carrier] - Filter messages by the carrier used for sending
24
- * @param {string} [params.senderPhoneNumber] - Filter messages by the sender's phone number
24
+ * @param {string} [params.senderPhoneNumber] - Filter messages by the sender's 5-6 digit Short Code or 10+ digit phone number
25
25
  * @param {string} [params.recipientPhoneNumber] - Filter messages by the recipient's phone number
26
26
  * @param {string} [params.startDate] - ISO-8601 date or timestamp; normalized to start-of-UTC-day (inclusive)
27
27
  * @param {string} [params.endDate] - ISO-8601 date or timestamp; normalized to end-of-UTC-day (inclusive). Hourly resolution is not supported
@@ -94,7 +94,7 @@ export class Messages {
94
94
  * @param {string} [params.startDate] - ISO-8601 date or timestamp; normalized to start-of-UTC-day (inclusive)
95
95
  * @param {string} [params.endDate] - ISO-8601 date or timestamp; normalized to end-of-UTC-day (inclusive). Hourly resolution is not supported
96
96
  * @param {boolean} [params.matchAny] - When true, the provided campaignId/brandId/phoneNumber filters are OR'd (match ANY) instead of the default most-specific-single behavior. subgroupId stays an AND scope. Opt-in/additive.
97
- * @param {string|string[]} [params.channel] - Filter analytics by channel (tenDLC, tollFree, p2p). Filters on the stored channel column; a tenDLC selection also includes older messages that have no channel, and p2p is a real channel here (matched by carrier). Accepts a single value or an array. Ignored when matchAny is true (matchAny takes precedence).
97
+ * @param {string|string[]} [params.channel] - Filter analytics by channel (tenDLC, tollFree, shortCode, p2p). Filters on the stored channel column; a tenDLC selection also includes older messages that have no channel, and p2p is a real channel here (matched by carrier). Accepts a single value or an array. Ignored when matchAny is true (matchAny takes precedence).
98
98
  * @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
99
99
  * @returns {Promise<Object>} - A promise that resolves to the analytics data
100
100
  */
@@ -117,7 +117,7 @@ export class Messages {
117
117
  * @param {string} [params.carrier] - Filter analytics by the carrier used for sending
118
118
  * @param {string} [params.startDate] - ISO-8601 date or timestamp; normalized to start-of-UTC-day (inclusive)
119
119
  * @param {string} [params.endDate] - ISO-8601 date or timestamp; normalized to end-of-UTC-day (inclusive). Hourly resolution is not supported
120
- * @param {string|string[]} [params.channel] - Filter analytics by channel (tenDLC, tollFree, p2p). Filters on the stored channel column; a tenDLC selection also includes older messages that have no channel, and p2p is a real channel here. Accepts a single value or an array.
120
+ * @param {string|string[]} [params.channel] - Filter analytics by channel (tenDLC, tollFree, shortCode, p2p). Filters on the stored channel column; a tenDLC selection also includes older messages that have no channel, and p2p is a real channel here. Accepts a single value or an array.
121
121
  * @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
122
122
  * @returns {Promise<Array>} - A promise that resolves to an array of analytics snapshot records
123
123
  */
@@ -159,7 +159,7 @@ export class Messages {
159
159
  * @param {string} [params.endDate]
160
160
  * @param {number} [params.page=1]
161
161
  * @param {number} [params.limit=50] - max 50 per page
162
- * @param {("tenDLC"|"tollFree"|"p2p"|"both")|Array<"tenDLC"|"tollFree"|"p2p">} [params.channel="both"] - Filter and rank rows by the stored channel column. "both" (default) means all activity. tenDLC/tollFree/p2p (single value or array) limit to the selected channel(s); toll-free is kept separate from 10DLC using the stored channel column.
162
+ * @param {("tenDLC"|"tollFree"|"shortCode"|"p2p"|"both")|Array<"tenDLC"|"tollFree"|"shortCode"|"p2p">} [params.channel="both"] - Filter and rank rows by the stored channel column. "both" (default) means all activity. tenDLC/tollFree/shortCode/p2p (single value or array) limit to the selected channel(s).
163
163
  * @param {import('../SignalHouseSDK').RequestOptions} [params.options]
164
164
  * @returns {Promise<Object>} - { rows, totalCount, page, limit }. Each row carries sms/mms/p2p metric columns plus `smsOptOuts` and `mmsOptOuts` from the DNC analytics MV (P2P has no opt-outs).
165
165
  */
@@ -185,7 +185,7 @@ export class Messages {
185
185
  * @param {string} [params.endDate]
186
186
  * @param {number} [params.page=1]
187
187
  * @param {number} [params.limit=50] - max 50 per page
188
- * @param {("tenDLC"|"tollFree"|"p2p"|"both")|Array<"tenDLC"|"tollFree"|"p2p">} [params.channel="both"] - Filter and rank rows by the stored channel column. "both" (default) means every code. tenDLC/tollFree/p2p (single value or array) limit to the selected channel(s); when one channel is selected, totalErrors reflects only that channel.
188
+ * @param {("tenDLC"|"tollFree"|"shortCode"|"p2p"|"both")|Array<"tenDLC"|"tollFree"|"shortCode"|"p2p">} [params.channel="both"] - Filter and rank rows by the stored channel column. "both" (default) means every code. tenDLC/tollFree/shortCode/p2p (single value or array) limit to the selected channel(s); when one channel is selected, totalErrors reflects only that channel.
189
189
  * @param {import('../SignalHouseSDK').RequestOptions} [params.options]
190
190
  * @returns {Promise<Object>} - { rows, totalCount, totalErrors: { sms, mms, p2p, all }, page, limit }
191
191
  */
@@ -208,7 +208,7 @@ export class Messages {
208
208
  * @param {string} [params.carrier] - Filter by the carrier
209
209
  * @param {string} [params.startDate] - ISO-8601 date or timestamp; normalized to start-of-UTC-day (inclusive)
210
210
  * @param {string} [params.endDate] - ISO-8601 date or timestamp; normalized to end-of-UTC-day (inclusive). Hourly resolution is not supported
211
- * @param {string|string[]} [params.channel] - Filter by channel (tenDLC, tollFree). Opt-outs are A2P-only, so p2p applies no filter. Filters on the stored channel column; a tenDLC selection also includes older opt-outs that have no channel.
211
+ * @param {string|string[]} [params.channel] - Filter by channel (tenDLC, tollFree, shortCode). Opt-outs are A2P-only, so p2p applies no filter. Filters on the stored channel column; a tenDLC selection also includes older opt-outs that have no channel.
212
212
  * @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
213
213
  * @returns {Promise<Object>} - A promise that resolves to the DNC analytics data (totals, byDate, byPhoneNumber, byCarrier)
214
214
  */
@@ -235,7 +235,7 @@ export class Messages {
235
235
  * @param {number} [params.limit] - Number of records per page
236
236
  * @param {string} [params.sortField] - Field to sort by
237
237
  * @param {string} [params.sortOrder] - Sort direction (asc, desc)
238
- * @param {string|string[]} [params.channel] - Filter by channel (tenDLC, tollFree). Opt-outs are A2P-only, so p2p applies no filter. Filters on the stored channel column; a tenDLC selection also includes older opt-outs that have no channel.
238
+ * @param {string|string[]} [params.channel] - Filter by channel (tenDLC, tollFree, shortCode). Opt-outs are A2P-only, so p2p applies no filter. Filters on the stored channel column; a tenDLC selection also includes older opt-outs that have no channel.
239
239
  * @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
240
240
  * @returns {Promise<Object>} - A promise that resolves to the paginated DNC records
241
241
  */
@@ -260,7 +260,7 @@ export class Messages {
260
260
  * @async
261
261
  * @roles api, admin, developer, billing, user
262
262
  * @param {Object} params - The parameters for sending an SMS message
263
- * @param {string} params.senderPhoneNumber - The phone number to send the message from
263
+ * @param {string} params.senderPhoneNumber - The 5-6 digit Short Code or 10+ digit phone number to send the message from
264
264
  * @param {string|string[]} params.recipientPhoneNumbers - The phone number(s) to send the message to
265
265
  * @param {string} params.messageBody - The body of the SMS message
266
266
  * @param {string} [params.statusCallbackUrl] - The URL to receive status callbacks
@@ -341,7 +341,7 @@ export class Messages {
341
341
  * @async
342
342
  * @roles api, admin, developer, billing, user
343
343
  * @param {Object} params - The parameters for sending an MMS message
344
- * @param {string} params.senderPhoneNumber - The phone number to send the message from
344
+ * @param {string} params.senderPhoneNumber - The 5-6 digit Short Code or 10+ digit phone number to send the message from
345
345
  * @param {string[]} params.recipientPhoneNumbers - The phone number(s) to send the message to
346
346
  * @param {string} params.messageBody - The body of the MMS message
347
347
  * @param {string[]} [params.mediaUrls] - The URLs of the media attachments
@@ -339,15 +339,18 @@ export class Numbers {
339
339
  }
340
340
 
341
341
  /**
342
- * Assign phone numbers to a campaign by providing the campaign ID and a list of phone numbers to assign
342
+ * Assign phone numbers to a campaign by providing the campaign ID and a list of phone numbers to assign.
343
+ * Numbers that are not found, already RELEASED, already assigned to a different campaign, on a mismatched
344
+ * DCA, or (for a 10DLC campaign without a number pool) beyond the remaining 49-number-per-campaign limit
345
+ * are not queued - each is reported back individually in `skipped` instead of failing the whole request.
343
346
  * @async
344
347
  * @roles api, admin, developer, billing, user
345
348
  * @param {Object} params - The parameters for assigning phone numbers to a campaign
346
349
  * @param {string} params.campaignId - The ID of the campaign to assign phone numbers to
347
350
  * @param {string[]} params.phoneNumbers - The list of phone numbers to assign to the campaign
348
351
  * @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
349
- * @throws {Error} Throws an error if the campaignId or phoneNumbers parameter is missing
350
- * @returns {Promise<Object>} A promise that resolves to the result of the assignment operation
352
+ * @throws {Error} Throws an error if the campaignId or phoneNumbers parameter is missing, if none of the phone numbers exist in the account, or if the campaign is not found or in a terminal state
353
+ * @returns {Promise<Object>} A promise that resolves to `{ Message, queued, skipped }` - `queued` numbers were sent for assignment; `skipped` numbers were rejected (not found, RELEASED, already assigned to a campaign, cross-DCA mismatch, or over the 49-number limit).
351
354
  */
352
355
  async assignPhoneNumberToCampaign({ campaignId, phoneNumbers, options = {} }) {
353
356
  this.client._require({ campaignId, phoneNumbers });
@@ -38,12 +38,16 @@ export class Onboarding {
38
38
  }
39
39
 
40
40
  /**
41
- * Get the caller's own onboarding record (customer-safe projection).
42
- * Returns `{ acceptedTermsOfService: boolean, emailVerified: boolean }`.
41
+ * Get the caller's own onboarding record (customer-safe projection) — the selections captured at
42
+ * signup plus the gate flags derived from them. Never returns internal KYC, staff assignment,
43
+ * referral, or external-reference data.
44
+ *
45
+ * Staff roles and sessions with no active group receive a synthetic verified, selection-free
46
+ * record rather than an error, so callers never have to special-case them.
43
47
  * @async
44
48
  * @param {Object} [params] - Optional parameters
45
49
  * @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
46
- * @returns {Promise<Object>} - `{ acceptedTermsOfService: boolean, emailVerified: boolean }`
50
+ * @returns {Promise<Object>} - `{ acceptedTermsOfService: boolean, emailVerified: boolean, products: string[], estimatedVolume: string|null, pendingExternalLink: "ghl"|"shopify"|null }`
47
51
  */
48
52
  async getMyOnboarding({ options = {} } = {}) {
49
53
  return this.client(`/group/onboarding/my`, { method: "GET", ...options });
@@ -60,6 +60,24 @@ export class Subgroups {
60
60
  return this.client(`/subgroup${queryString}`, { method: "GET", ...options });
61
61
  }
62
62
 
63
+ /**
64
+ * Get resource counts grouped by subgroup ID. Only signalhouse_admin can query all groups;
65
+ * every other allowed role is restricted to its JWT group scope.
66
+ * @async
67
+ * @roles signalhouse, api, admin, developer, billing, user
68
+ * @param {Object} [params={}] - Optional count filters.
69
+ * @param {string} [params.groupId] - Restrict results to a group.
70
+ * @param {string} [params.subgroupId] - Restrict results to a subgroup.
71
+ * @param {number} [params.limit] - Maximum rows to return.
72
+ * @param {number} [params.offset] - Rows to skip; requires limit.
73
+ * @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
74
+ * @returns {Promise<Object>} - Subgroup-level resource count rows.
75
+ */
76
+ async getSubgroupCounts({ groupId, subgroupId, limit, offset, options = {} } = {}) {
77
+ const queryString = this.client._getQueryString({ groupId, subgroupId, limit, offset });
78
+ return this.client(`/subgroup/counts${queryString}`, { method: "GET", ...options });
79
+ }
80
+
63
81
  /**
64
82
  * Create a new subgroup with the specified subgroup data
65
83
  * @async
@@ -32,6 +32,20 @@ export class Tickets {
32
32
  return this.client(`/admin/ticket/jira/epics${queryString}`, { method: "GET", ...options });
33
33
  },
34
34
 
35
+ /**
36
+ * Search open non-Epic, non-Subtask issues for the Subtask parent picker. Staff-only.
37
+ * @async
38
+ * @roles signalhouse
39
+ * @param {Object} [params]
40
+ * @param {string} [params.query] - Optional issue summary/key filter
41
+ * @param {import('../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request
42
+ * @returns {Promise<Object>} - `{ issues: Array<{ id, key, summary }> }`
43
+ */
44
+ searchJiraParentIssues: async ({ query, options = {} } = {}) => {
45
+ const queryString = this.client._getQueryString({ query });
46
+ return this.client(`/admin/ticket/jira/parent-issues${queryString}`, { method: "GET", ...options });
47
+ },
48
+
35
49
  /**
36
50
  * Search assignable Jira users for the assignee picker. Staff-only.
37
51
  * @async
@@ -147,44 +147,50 @@ export class SipProfiles {
147
147
  }
148
148
 
149
149
  /**
150
- * Assign a phone number to this SIP profile (routes inbound calls on that
151
- * number to the endpoint).
150
+ * Assign a phone number to this SIP profile — routes inbound calls on that
151
+ * number to the endpoint. Prefer `e164` (numbers are Mongo-authoritative); the
152
+ * legacy `phoneNumberId` (a voice-backend phone-number UUID) is still accepted.
152
153
  * @async
153
154
  * @roles api, admin, developer, billing, user
154
155
  * @param {Object} params - Request parameters.
155
156
  * @param {string} params.id - The SIP profile UUID.
156
- * @param {string} params.phoneNumberId - The phone number UUID to assign.
157
+ * @param {string} [params.e164] - The phone number in E.164 to assign (preferred).
158
+ * @param {string} [params.phoneNumberId] - Legacy: a voice-backend phone number UUID.
157
159
  * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
158
- * @throws {Error} If id or phoneNumberId is missing.
160
+ * @throws {Error} If id is missing, or neither e164 nor phoneNumberId is provided.
159
161
  * @returns {Promise<Object>} `{ sipProfile: {...} }`.
160
162
  */
161
- async assignNumber({ id, phoneNumberId, options = {} }) {
162
- this.client._require({ id, phoneNumberId });
163
+ async assignNumber({ id, e164, phoneNumberId, options = {} }) {
164
+ this.client._require({ id });
165
+ if (!e164 && !phoneNumberId) this.client._require({ e164 });
163
166
  const safeId = encodeURIComponent(id);
164
167
  return this.client(`/voice/sip-profiles/${safeId}/assign-number`, {
165
168
  method: "POST",
166
- body: { phoneNumberId },
169
+ body: e164 ? { e164 } : { phoneNumberId },
167
170
  ...options,
168
171
  });
169
172
  }
170
173
 
171
174
  /**
172
- * Unassign a phone number from this SIP profile.
175
+ * Unassign a phone number from this SIP profile. Prefer `e164`; the legacy
176
+ * `phoneNumberId` (a voice-backend phone-number UUID) is still accepted.
173
177
  * @async
174
178
  * @roles api, admin, developer, billing, user
175
179
  * @param {Object} params - Request parameters.
176
180
  * @param {string} params.id - The SIP profile UUID.
177
- * @param {string} params.phoneNumberId - The phone number UUID to unassign.
181
+ * @param {string} [params.e164] - The phone number in E.164 to unassign (preferred).
182
+ * @param {string} [params.phoneNumberId] - Legacy: a voice-backend phone number UUID.
178
183
  * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
179
- * @throws {Error} If id or phoneNumberId is missing.
184
+ * @throws {Error} If id is missing, or neither e164 nor phoneNumberId is provided.
180
185
  * @returns {Promise<Object>} `{ sipProfile: {...} }`.
181
186
  */
182
- async unassignNumber({ id, phoneNumberId, options = {} }) {
183
- this.client._require({ id, phoneNumberId });
187
+ async unassignNumber({ id, e164, phoneNumberId, options = {} }) {
188
+ this.client._require({ id });
189
+ if (!e164 && !phoneNumberId) this.client._require({ e164 });
184
190
  const safeId = encodeURIComponent(id);
185
191
  return this.client(`/voice/sip-profiles/${safeId}/unassign-number`, {
186
192
  method: "POST",
187
- body: { phoneNumberId },
193
+ body: e164 ? { e164 } : { phoneNumberId },
188
194
  ...options,
189
195
  });
190
196
  }
@@ -61,4 +61,30 @@ export class Tokens {
61
61
  async create({ tokenData = {}, options = {} } = {}) {
62
62
  return this.client(`/voice/tokens`, { method: "POST", body: tokenData, ...options });
63
63
  }
64
+
65
+ /**
66
+ * Refresh (extend) an existing voice token in place — identity-preserving.
67
+ * Wraps voice-backend's `/voice/tokens/refresh`. Keeps the same wrtc_
68
+ * identity + SIP credentials and only pushes out `expires_at`, so a
69
+ * long-lived softphone stays registered without re-establishing its SIP
70
+ * registration (unlike `create()`, which mints a NEW identity). Provide the
71
+ * current `token` (its identity is decoded server-side) or the `identity`
72
+ * directly. Rejects with a 404 when the token no longer exists for the
73
+ * account — mint a fresh one with `create()` in that case.
74
+ * @async
75
+ * @roles api, admin, developer, billing, user
76
+ * @param {Object} [params] - Request parameters.
77
+ * @param {string} [params.token] - The current voice token (JWT) whose identity to extend.
78
+ * @param {string} [params.identity] - The wrtc_ identity to extend, if held directly (from a prior mint/refresh response).
79
+ * @param {number} [params.ttl] - New TTL in seconds. Defaults to the server default, capped at the server maximum.
80
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
81
+ * @returns {Promise<CreateTokenResponse>} The refreshed token + embedded SIP credentials (same shape as create()).
82
+ */
83
+ async refresh({ token, identity, ttl, options = {} } = {}) {
84
+ const body = {};
85
+ if (token) body.token = token;
86
+ if (identity) body.identity = identity;
87
+ if (ttl != null) body.ttl = ttl;
88
+ return this.client(`/voice/tokens/refresh`, { method: "POST", body, ...options });
89
+ }
64
90
  }
@@ -11,9 +11,14 @@ import { Call } from "./Call.js";
11
11
 
12
12
  /**
13
13
  * @typedef {Object} VoiceTokenResponse
14
- * @property {string} token - JWT (informational; SDK does not currently re-send it).
15
- * @property {string} identity - The wrtc_<hex> SIP username.
16
- * @property {string} expires_at - ISO timestamp.
14
+ * @property {string} token - JWT (informational). Tokens are ephemeral: refresh
15
+ * before `expires_at` (see updateToken()) so the softphone keeps a valid
16
+ * registration.
17
+ * @property {string} identity - The wrtc_<hex> SIP username. The refresh
18
+ * endpoint (POST /voice/tokens/refresh) keeps this identity stable; a fresh
19
+ * mint (POST /voice/tokens) returns a new one.
20
+ * @property {string} expires_at - ISO timestamp. The Device schedules a
21
+ * `tokenWillExpire` event at this minus `tokenRefreshMs`.
17
22
  * @property {SipCredentials} sip_credentials
18
23
  */
19
24
 
@@ -40,12 +45,33 @@ import { Call } from "./Call.js";
40
45
  * const call = await device.connect({ to: "+15551234", from: "+15559999" });
41
46
  * device.on("incoming", call => call.accept());
42
47
  *
43
- * Token shape mirrors what `POST /voice/tokens` returns from the SignalHouse
44
- * voice-backend. You can also hand in the inner `sip_credentials` object
45
- * directly if your app already unwrapped the response.
48
+ * Token refresh (REQUIRED for long-lived sessions): voice tokens are short-
49
+ * lived — a token's SIP credential authenticates only until `expires_at`. If
50
+ * you let it lapse, the softphone can no longer re-register and inbound calls
51
+ * stop arriving (they go to voicemail). The Device raises a `tokenWillExpire`
52
+ * event shortly before expiry so you can keep it alive. Mirrors Twilio's
53
+ * Device.updateToken() model:
54
+ *
55
+ * // Manual — fetch a fresh token from your backend and hand it back:
56
+ * device.on("tokenWillExpire", async () => {
57
+ * const fresh = await fetch("/my/backend/voice-token").then((r) => r.json());
58
+ * await device.updateToken(fresh);
59
+ * });
60
+ *
61
+ * // Automatic — let the Device refresh itself:
62
+ * const device = new Device(tokenResponse, {
63
+ * tokenProvider: () => fetch("/my/backend/voice-token").then((r) => r.json()),
64
+ * });
65
+ *
66
+ * Preferred: have your backend call POST /voice/tokens/refresh, which extends
67
+ * the SAME identity in place — the Device then just reschedules and keeps its
68
+ * existing registration (no re-register, seamless). If instead you mint a fresh
69
+ * token (new identity), the Device re-registers under it (break-before-make,
70
+ * deferred while a call is active).
46
71
  *
47
72
  * Events: "registered", "unregistered", "registrationFailed", "incoming",
48
- * "connected" (WSS established), "disconnected", "error".
73
+ * "connected" (WSS established), "disconnected", "tokenWillExpire",
74
+ * "tokenRefreshFailed", "error".
49
75
  */
50
76
  export class Device {
51
77
  /**
@@ -54,25 +80,36 @@ export class Device {
54
80
  * @param {number} [opts.registerExpires=600] - REGISTER expires interval, in seconds.
55
81
  * @param {string} [opts.userAgent="SignalHouse-Voice-SDK/0.1"]
56
82
  * @param {boolean} [opts.autoRegister=false] - Call register() on construct.
83
+ * @param {number} [opts.tokenRefreshMs=30000] - How long before `expires_at`
84
+ * to raise `tokenWillExpire` (and auto-refresh, when `tokenProvider` is set).
85
+ * @param {() => (VoiceTokenResponse|Promise<VoiceTokenResponse>)} [opts.tokenProvider]
86
+ * Optional. A function YOU supply that returns a fresh token — ideally by
87
+ * calling POST /voice/tokens/refresh through your backend (identity-
88
+ * preserving), or a fresh mint. When set, the Device calls it on
89
+ * `tokenWillExpire` and applies the result via updateToken() automatically.
57
90
  */
58
91
  constructor(tokenOrCreds, opts = {}) {
59
- const creds = tokenOrCreds?.sip_credentials || tokenOrCreds;
60
- if (!creds?.username || !creds?.password || !creds?.domain || !creds?.wss_url) {
61
- throw new Error("Device: token/credentials missing required fields (username, password, domain, wss_url)");
62
- }
63
-
64
- this.identity = tokenOrCreds?.identity || creds.username;
92
+ this._credentials = this._parseCreds(tokenOrCreds, "Device");
93
+ this.identity = tokenOrCreds?.identity || this._credentials.username;
65
94
  this.expiresAt = tokenOrCreds?.expires_at || null;
66
95
 
67
- this._credentials = creds;
68
96
  this._opts = {
69
97
  registerExpires: opts.registerExpires ?? 600,
70
98
  userAgent: opts.userAgent ?? "SignalHouse-Voice-SDK/0.1",
99
+ tokenRefreshMs: opts.tokenRefreshMs ?? 30000,
100
+ tokenProvider: typeof opts.tokenProvider === "function" ? opts.tokenProvider : null,
71
101
  };
72
102
 
73
103
  this._ua = null;
74
104
  this._listeners = new Map();
75
105
  this._registered = false;
106
+ this._stopped = false; // set by unregister(); guards in-flight refreshes
107
+
108
+ // Token-refresh state.
109
+ this._refreshTimer = null; // fires `tokenWillExpire` before expiry
110
+ this._activeSessions = new Set(); // in-flight calls; defer an identity swap while busy
111
+ this._pendingToken = null; // identity-change token handed to updateToken() while busy
112
+ this._refreshPendingAuto = false; // tokenProvider refresh deferred while busy
76
113
 
77
114
  if (opts.autoRegister) this.register().catch(() => { /* surfaced via "registrationFailed" event */ });
78
115
  }
@@ -81,10 +118,12 @@ export class Device {
81
118
 
82
119
  /**
83
120
  * Open the WSS connection and REGISTER. Resolves on first "registered"
84
- * event, rejects on first "registrationFailed".
121
+ * event, rejects on first "registrationFailed". On success, schedules the
122
+ * `tokenWillExpire` refresh timer from the current token's `expires_at`.
85
123
  * @returns {Promise<void>}
86
124
  */
87
125
  register() {
126
+ this._stopped = false; // an intentional (re)start re-arms the device
88
127
  if (this._registered) return Promise.resolve();
89
128
  if (!this._ua) this._initUA();
90
129
 
@@ -92,6 +131,7 @@ export class Device {
92
131
  const onOk = () => {
93
132
  this._ua.removeListener("registered", onOk);
94
133
  this._ua.removeListener("registrationFailed", onFail);
134
+ this._scheduleTokenRefresh();
95
135
  resolve();
96
136
  };
97
137
  const onFail = (e) => {
@@ -106,18 +146,55 @@ export class Device {
106
146
  }
107
147
 
108
148
  /**
109
- * Unregister and close the WSS connection. Idempotent.
149
+ * Unregister and close the WSS connection. Idempotent. Marks the device
150
+ * stopped so any in-flight token refresh cannot bring it back online, and
151
+ * cancels the refresh timer + pending state.
110
152
  * @returns {Promise<void>}
111
153
  */
112
154
  unregister() {
113
- if (!this._ua) return Promise.resolve();
114
- return new Promise((resolve) => {
115
- const done = () => { try { this._ua.stop(); } catch {} resolve(); };
116
- if (!this._registered) return done();
117
- this._ua.once("unregistered", done);
118
- try { this._ua.unregister(); }
119
- catch { done(); }
120
- });
155
+ this._stopped = true;
156
+ this._clearTokenRefresh();
157
+ this._pendingToken = null;
158
+ this._refreshPendingAuto = false;
159
+ return this._teardownUA();
160
+ }
161
+
162
+ /**
163
+ * Apply a fresh token so the registration never lapses — call this in
164
+ * response to `tokenWillExpire` (or let `tokenProvider` do it).
165
+ *
166
+ * Identity-preserving fast path: if the token carries the SAME identity +
167
+ * password as the current one (i.e. your backend called POST
168
+ * /voice/tokens/refresh to extend in place), the existing registration is
169
+ * still valid, so the Device just updates `expires_at` and reschedules — no
170
+ * re-register, fully seamless, safe even mid-call.
171
+ *
172
+ * Identity change (a fresh mint): the Device re-registers under the new
173
+ * identity (break-before-make), deferred while a call is active and applied
174
+ * once the last call ends, so live audio is never disrupted.
175
+ *
176
+ * @param {VoiceTokenResponse | SipCredentials} tokenOrCreds
177
+ * @returns {Promise<void>} resolves once applied (or immediately when a swap
178
+ * is deferred behind an active call).
179
+ */
180
+ async updateToken(tokenOrCreds) {
181
+ const creds = this._parseCreds(tokenOrCreds, "Device.updateToken");
182
+ const newIdentity = tokenOrCreds?.identity || creds.username;
183
+
184
+ // Identity-preserving fast path — same credential, just extended expiry.
185
+ if (this._ua && this._registered && !this._stopped &&
186
+ newIdentity === this.identity && creds.password === this._credentials.password) {
187
+ this.expiresAt = tokenOrCreds?.expires_at || this.expiresAt;
188
+ this._scheduleTokenRefresh();
189
+ return;
190
+ }
191
+
192
+ // Identity changed → full re-register; defer while a call is active.
193
+ if (this._activeSessions.size > 0) {
194
+ this._pendingToken = tokenOrCreds;
195
+ return;
196
+ }
197
+ await this._applyToken(tokenOrCreds);
121
198
  }
122
199
 
123
200
  /**
@@ -162,6 +239,7 @@ export class Device {
162
239
  extraHeaders: headers,
163
240
  });
164
241
 
242
+ this._trackSession(session);
165
243
  return new Call({ session, direction: "outbound" });
166
244
  }
167
245
 
@@ -192,6 +270,173 @@ export class Device {
192
270
  }
193
271
  }
194
272
 
273
+ /**
274
+ * Unwrap `sip_credentials` from a token response (or accept a bare creds
275
+ * object) and validate the four required SIP fields.
276
+ * @param {VoiceTokenResponse | SipCredentials} tokenOrCreds
277
+ * @param {string} ctx - Caller label used in the error message.
278
+ * @returns {SipCredentials}
279
+ * @throws {Error} when a required field is missing.
280
+ */
281
+ _parseCreds(tokenOrCreds, ctx) {
282
+ const creds = tokenOrCreds?.sip_credentials || tokenOrCreds;
283
+ if (!creds?.username || !creds?.password || !creds?.domain || !creds?.wss_url) {
284
+ throw new Error(`${ctx}: token/credentials missing required fields (username, password, domain, wss_url)`);
285
+ }
286
+ return creds;
287
+ }
288
+
289
+ /**
290
+ * Stop the current UA and close its WSS, without touching lifecycle flags —
291
+ * used both by unregister() and by _applyToken()'s break-before-make swap.
292
+ * @returns {Promise<void>}
293
+ */
294
+ _teardownUA() {
295
+ if (!this._ua) return Promise.resolve();
296
+ return new Promise((resolve) => {
297
+ const ua = this._ua;
298
+ const done = () => { try { ua.stop(); } catch {} resolve(); };
299
+ if (!this._registered) return done();
300
+ ua.once("unregistered", done);
301
+ try { ua.unregister(); }
302
+ catch { done(); }
303
+ });
304
+ }
305
+
306
+ /**
307
+ * Re-register under fresh credentials when the identity changed
308
+ * (break-before-make). Only invoked when no call is active (updateToken
309
+ * defers while busy). Bails if the app tore the device down mid-swap; if the
310
+ * new registration fails, surfaces `tokenRefreshFailed` and re-arms a short
311
+ * retry so the device recovers rather than sitting unregistered.
312
+ * @param {VoiceTokenResponse | SipCredentials} tokenOrCreds
313
+ */
314
+ async _applyToken(tokenOrCreds) {
315
+ const creds = this._parseCreds(tokenOrCreds, "Device.updateToken");
316
+ this._clearTokenRefresh();
317
+ try { await this._teardownUA(); } catch { /* ignore — tearing down anyway */ }
318
+ if (this._stopped) return; // unregister() ran while we were tearing down
319
+
320
+ this._ua = null;
321
+ this._registered = false;
322
+ this._credentials = creds;
323
+ this.identity = tokenOrCreds?.identity || creds.username;
324
+ this.expiresAt = tokenOrCreds?.expires_at || null;
325
+
326
+ try {
327
+ await this.register(); // rebuilds the UA + reschedules the refresh timer
328
+ } catch (err) {
329
+ this._emit("tokenRefreshFailed", { error: err });
330
+ if (!this._stopped) {
331
+ this._clearTokenRefresh();
332
+ this._refreshTimer = setTimeout(() => this._onTokenWillExpire(), 5000);
333
+ }
334
+ throw err; // propagate to a manual updateToken() caller
335
+ }
336
+ }
337
+
338
+ /** `expires_at` as epoch ms, or null when unparseable/absent. */
339
+ _expiresAtMs() {
340
+ const t = Date.parse(this.expiresAt || "");
341
+ return Number.isFinite(t) ? t : null;
342
+ }
343
+
344
+ /**
345
+ * True if the given token has too little life left to safely apply (would
346
+ * expire within tokenRefreshMs). Tokens with no parseable expiry are treated
347
+ * as usable.
348
+ * @param {VoiceTokenResponse} tokenOrCreds
349
+ * @returns {boolean}
350
+ */
351
+ _isTokenStale(tokenOrCreds) {
352
+ const exp = Date.parse(tokenOrCreds?.expires_at || "");
353
+ if (!Number.isFinite(exp)) return false;
354
+ return exp - Date.now() <= this._opts.tokenRefreshMs;
355
+ }
356
+
357
+ /** Arm the one-shot `tokenWillExpire` timer for tokenRefreshMs before expiry. */
358
+ _scheduleTokenRefresh() {
359
+ this._clearTokenRefresh();
360
+ const expMs = this._expiresAtMs();
361
+ if (expMs === null) return; // no expiry info → caller manages refresh manually
362
+ const delay = Math.max(0, expMs - Date.now() - this._opts.tokenRefreshMs);
363
+ this._refreshTimer = setTimeout(() => this._onTokenWillExpire(), delay);
364
+ }
365
+
366
+ /** Cancel the pending `tokenWillExpire` timer, if any. */
367
+ _clearTokenRefresh() {
368
+ if (this._refreshTimer) { clearTimeout(this._refreshTimer); this._refreshTimer = null; }
369
+ }
370
+
371
+ /**
372
+ * Fired shortly before the current token expires. Emits `tokenWillExpire`
373
+ * for manual handlers; if a `tokenProvider` is configured, also fetches a
374
+ * fresh token and applies it. Deferred while a call is active (the fetch
375
+ * happens only once idle, so a fetched token can't go stale in hand). Bails
376
+ * if the device was torn down while the fetch was in flight.
377
+ */
378
+ async _onTokenWillExpire() {
379
+ if (this._stopped) return;
380
+ this._emit("tokenWillExpire", { identity: this.identity, expiresAt: this.expiresAt });
381
+ if (!this._opts.tokenProvider) return; // app handles it via updateToken()
382
+ if (this._activeSessions.size > 0) {
383
+ this._refreshPendingAuto = true; // fetch + apply once the call ends
384
+ return;
385
+ }
386
+ try {
387
+ const fresh = await this._opts.tokenProvider();
388
+ if (this._stopped) return; // torn down while the provider fetch was pending
389
+ if (!fresh) throw new Error("tokenProvider returned no token");
390
+ await this.updateToken(fresh);
391
+ } catch (err) {
392
+ if (this._stopped) return;
393
+ this._emit("tokenRefreshFailed", { error: err });
394
+ // Retry shortly so a transient failure doesn't strand us, provided
395
+ // there's still time before the current token expires.
396
+ const expMs = this._expiresAtMs();
397
+ if (expMs !== null && expMs - Date.now() > 5000) {
398
+ this._clearTokenRefresh();
399
+ this._refreshTimer = setTimeout(() => this._onTokenWillExpire(), 5000);
400
+ }
401
+ }
402
+ }
403
+
404
+ /** Track a call session so an identity swap can be deferred until it ends. */
405
+ _trackSession(session) {
406
+ if (!session) return;
407
+ this._activeSessions.add(session);
408
+ const done = () => this._untrackSession(session);
409
+ try { session.on("ended", done); session.on("failed", done); }
410
+ catch { /* not a JsSIP session — best-effort tracking only */ }
411
+ }
412
+
413
+ /**
414
+ * Stop tracking a finished session. When the line goes fully idle, apply any
415
+ * token refresh that was deferred behind the call: a `tokenProvider` fetch
416
+ * (fresh) or a stored identity-change token — re-fetching instead if that
417
+ * stored token has gone stale while the call ran.
418
+ * @param {object} session - The JsSIP session that ended/failed.
419
+ */
420
+ _untrackSession(session) {
421
+ this._activeSessions.delete(session);
422
+ if (this._activeSessions.size > 0 || this._stopped) return;
423
+
424
+ if (this._refreshPendingAuto) {
425
+ this._refreshPendingAuto = false;
426
+ this._onTokenWillExpire();
427
+ } else if (this._pendingToken) {
428
+ const pending = this._pendingToken;
429
+ this._pendingToken = null;
430
+ // A long call can outlive the token we stashed — don't register with
431
+ // stale creds; get a fresh one the same way the timer would.
432
+ if (this._isTokenStale(pending)) {
433
+ this._onTokenWillExpire();
434
+ } else {
435
+ this._applyToken(pending).catch((err) => this._emit("tokenRefreshFailed", { error: err }));
436
+ }
437
+ }
438
+ }
439
+
195
440
  _initUA() {
196
441
  const socket = new JsSIP.WebSocketInterface(this._credentials.wss_url);
197
442
  this._ua = new JsSIP.UA({
@@ -219,6 +464,7 @@ export class Device {
219
464
 
220
465
  this._ua.on("newRTCSession", (e) => {
221
466
  if (e.originator !== "remote") return; // outbound: we already wrap in connect()
467
+ this._trackSession(e.session);
222
468
  const call = new Call({ session: e.session, direction: "inbound", request: e.request });
223
469
  this._emit("incoming", call);
224
470
  });