@signalhousellc/sdk 1.0.68 → 1.0.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,13 +55,3 @@ For full API reference and advanced usage, visit https://api.signalhouse.io
55
55
 
56
56
  License
57
57
  ISC © Signal House
58
-
59
-
60
- ### Geographic availability search
61
-
62
- The availability path supports optional `city` as a case-insensitive prefix within an explicit `country` (`CA` or `US`) and `state` (province/state code). City must be supplied with both country and state. Example: `getAvailablePhoneNumbers({ country: "CA", state: "QC", city: "Charny", npa: "367", nxx: "883", limit: 10 })`. Canadian province, city, NPA and NXX matches are checked before the result limit; unrelated substring matches are excluded. Availability may change before purchase.
63
-
64
-
65
- Canadian message estimates return per-recipient `phoneNumber`, `rate`, `amount` and `fallback`, plus segment count and total in microdollars. They use cached carrier information without exposing carrier names or making a paid lookup. Rates remain 75,500 for standard carriers and 81,000 for Ice Wireless/Iristel or unresolved carriers; MMS/group MMS use one segment per recipient.
66
-
67
- Available-number searches return `{ numbers, numberCount? }`. `numberCount` is omitted when the total is unknown, including Canadian geographic and US city searches; the page length is not a total. These filtered searches have a 20-second discovery deadline and return HTTP 503 when incomplete. Narrow the location/NPA/NXX or retry later. Successful Infobip samples may be cached for 15 seconds across pages; purchase rechecks availability.
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@signalhousellc/sdk",
3
- "version": "1.0.68",
3
+ "version": "1.0.69",
4
4
  "description": "Signal House SDK for use with the Signal House platform",
5
5
  "type": "module",
6
6
  "main": "src/SignalHouseSDK.js",
7
7
  "exports": {
8
8
  ".": "./src/SignalHouseSDK.js",
9
- "./voice-browser": "./src/domains/voice/browser/index.js"
9
+ "./voice-browser": "./src/domains/voice/browser/index.js",
10
+ "./webhooks": "./src/webhooks/verifySignature.js"
10
11
  },
11
12
  "files": [
12
13
  "src/",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "source": "https://github.com/bonzo-signalhouse/agent-skills.git",
3
- "ref": "v0.2.0",
3
+ "ref": "v0.3.0",
4
4
  "skills": [
5
5
  "signalhouse-10dlc",
6
6
  "signalhouse-advisor",
@@ -10,13 +10,13 @@
10
10
  "signalhouse-webhooks"
11
11
  ],
12
12
  "files": {
13
- "signalhouse-10dlc/SKILL.md": "b10ed2f759edcfcc9099c56285341ff0fbc9c131b2fd4b2d47922067128e79c2",
13
+ "signalhouse-10dlc/SKILL.md": "6a46fddf1572b39849c07855c6ece3d3499c711e5ffa6f466d73ab54182fd49f",
14
14
  "signalhouse-10dlc/examples/brightsmile-onboarding.md": "b5c04a551bc918f2563f1d1e59e516cf2e60fa7233490e3312a92dd1d1e9d473",
15
- "signalhouse-10dlc/references/tcr-rejection-causes.md": "8347a1656035626e0fc77dca49a0d06800e077ec8a4dc6e7e89208d90e8aeb2f",
16
- "signalhouse-advisor/SKILL.md": "9e3542f4b8c56474db4167535b2310432456bf935578a160a255b8a7d84d7738",
17
- "signalhouse-numbers/SKILL.md": "47ebcd18cb0bda80ebfefa1165540117c4712691356d602c76ab9e29a69c6626",
18
- "signalhouse-setup/SKILL.md": "0e3743d9a6adf8516d0d194c952090af2875605c82d3a397c58e49e0f4d56cde",
19
- "signalhouse-sms/SKILL.md": "e40c5a9da90c2ba75a34de8f5048b2cde3f202f6eb44067352dd32fce8988ce5",
20
- "signalhouse-webhooks/SKILL.md": "2b47468bcdc581b32b3697fcaab05b1fd16e279e139c915d83ceed1ce1ddd57f"
15
+ "signalhouse-10dlc/references/tcr-rejection-causes.md": "312d754c55dfdf8e21d085724728ae4b2288d7b485fba1c61c78f94a42780e8c",
16
+ "signalhouse-advisor/SKILL.md": "5e5070dc4742a1fe0195989471f809170c30fd7d8f63c33e4778215a2a446146",
17
+ "signalhouse-numbers/SKILL.md": "f996be53809ea2f90e49b32452f162eeff5369056e1f63e422b8fdb46d2896ff",
18
+ "signalhouse-setup/SKILL.md": "ca87a6d71f3f82614b83ad283971385fc55c13c097b76595b1b85def235373ec",
19
+ "signalhouse-sms/SKILL.md": "ec7ed8985ad590b0ab5d40c9ddb003eb8cbc566a1309ac5e330f570e0404ec42",
20
+ "signalhouse-webhooks/SKILL.md": "d16062e9e0a7cdda3c02aee485bd5114659405d00b1d635da1280db0179490a7"
21
21
  }
22
22
  }
@@ -84,17 +84,13 @@ The two with constrained values, which is where callers get stuck:
84
84
  | Field | Values |
85
85
  |---|---|
86
86
  | `entityType` | `PRIVATE_PROFIT`, `PUBLIC_PROFIT`, `NON_PROFIT`, `GOVERNMENT` |
87
- | `vertical` | one of 23 industry values: `PROFESSIONAL`, `REAL_ESTATE`, `HEALTHCARE`, `FINANCIAL`, `TECHNOLOGY`, `RETAIL` and so on. Do not guess; read the list from the registration guide. |
87
+ | `vertical` | one of 23 industry values: `PROFESSIONAL`, `REAL_ESTATE`, `HEALTHCARE`, `FINANCIAL`, `TECHNOLOGY`, `RETAIL` and so on. Do not guess; read the list from the Brands reference. |
88
88
 
89
89
  **`brandRelationship` is not a field you set.** The service overwrites it before validation runs,
90
90
  and every 10DLC brand ends up `MEDIUM_ACCOUNT` regardless of what was sent. Whatever a caller
91
91
  passes is discarded silently. Do not ask a customer to choose one, and do not report it back as
92
92
  something they selected.
93
93
 
94
- > ⚠️ The 10DLC Registration Guide's own required-fields table still lists `brandRelationship` as a
95
- > required, caller-chosen field. It is wrong on this point; `03-api-reference/02-brands.md` and
96
- > the service code agree that it is ignored. Tracked as **SHGHL-2946**.
97
-
98
94
  `PUBLIC_PROFIT` additionally requires `stockSymbol`, `stockExchange`, `website` and
99
95
  `businessContactEmail`.
100
96
 
@@ -166,11 +162,11 @@ Never state a current status, price, or throughput figure from memory. Read it:
166
162
  - **Whether a specific send will be allowed:** attempt it and read the error, or check campaign
167
163
  state first. There is no separate preflight endpoint.
168
164
 
169
- - **The full field lists, verticals, use cases, status meanings and worked examples:** the
170
- internal 10DLC Registration Guide (`documentation/dev-documentation/07-10dlc-registration.md`)
171
- and the published help-centre articles *10DLC Messaging Overview*, *10DLC Campaign Website
165
+ - **The full field lists, verticals, use cases, status meanings and worked examples:** the Brands
166
+ and Campaigns references (https://app2.signalhouse.io/docs/brands,
167
+ https://app2.signalhouse.io/docs/campaigns) and the published help-centre articles *10DLC Messaging Overview*, *10DLC Campaign Website
172
168
  Review* and *10DLC Campaign Approvals*. This skill is a condensed guardrail, not a substitute
173
- for them. Where it and the guide disagree, the guide wins and this file is the bug.
169
+ for them. Where it and the references disagree, the references win and this file is the bug.
174
170
 
175
171
  The platform enforces every constraint on this page. This skill exists so an agent stops
176
172
  *before* a rejected registration or a failed send, not so it can predict the outcome.
@@ -5,8 +5,7 @@ Loaded on demand, and only needed once something has actually been rejected.
5
5
  Every cause below is drawn from a **real Signal House support ticket** or verified against the
6
6
  API, with the source noted. Nothing here is general industry knowledge.
7
7
 
8
- > **Review status:** engineer-verified. Still needs the named business reviewer per
9
- > [CONTRIBUTING.md](../../../CONTRIBUTING.md), since this is compliance-bearing content.
8
+ > **Review status:** engineer-verified against support tickets and the API.
10
9
 
11
10
  ## How to read a rejection
12
11
 
@@ -43,9 +42,10 @@ Two distinct real causes share this code.
43
42
  **`Invalid sub-usecases: SOCIAL`.** `SOCIAL` is not a valid sub-use-case under any parent,
44
43
  neither `LOW_VOLUME` nor `MIXED`. Valid sub-use-cases for `LOW_VOLUME` are
45
44
  `ACCOUNT_NOTIFICATION`, `CUSTOMER_CARE`, `DELIVERY_NOTIFICATION`, `MARKETING`. Signal House's own
46
- validation now rejects `SOCIAL` as a `subUsecases` value with an immediate 400 before the request
47
- ever reaches TCR (SHGHL-2943), so this specific 501 should no longer be reachable through the
48
- API — `SOCIAL` is still a valid top-level `usecase`, just never a sub-use-case.
45
+ validation now rejects `SOCIAL` as a `subUsecases` value with an immediate 400, before the request
46
+ reaches TCR, so this 501 should no longer be reachable through the API. `SOCIAL` is still a valid
47
+ top-level `usecase`, just never a sub-use-case. If an older campaign carries this rejection,
48
+ resubmit with `SOCIAL` removed from `subUsecases`.
49
49
 
50
50
  **Punctuation in a tag.** A tag of `maria-answers` was rejected for containing a hyphen. The
51
51
  campaign content was fine. Fix the tag (`mariaanswers`), then the campaign has to be
@@ -102,10 +102,3 @@ for `PENDING_REVIEW` / `REJECTED` / `TCR_REGISTER_FAILED` when the campaign is *
102
102
  at the moment it fails. This is deliberate, because those states are appealable and re-registerable,
103
103
  but it means a customer who abandons failed campaigns keeps funds tied up. Deleting the dead
104
104
  campaign frees them.
105
-
106
- ## Open questions for the business reviewer
107
-
108
- 1. **The full valid sub-use-case set per parent use case.** Four are confirmed for `LOW_VOLUME`
109
- from a support ticket (`ACCOUNT_NOTIFICATION`, `CUSTOMER_CARE`, `DELIVERY_NOTIFICATION`,
110
- `MARKETING`). The complete mapping should come from TCR's own documentation rather than being
111
- inferred from the tickets we happen to have.
@@ -78,7 +78,7 @@ constraint is nearly always the part that changes the plan.
78
78
 
79
79
  ## Where to look it up
80
80
 
81
- - **The full setup pipeline in one place:** the internal 10DLC Registration guide
82
- (`documentation/dev-documentation/07-10dlc-registration.md`).
83
- - **A first working call:** the Quickstart (`01-quickstart.md`).
81
+ - **The full setup pipeline:** the Brands and Campaigns references
82
+ (https://app2.signalhouse.io/docs/brands, https://app2.signalhouse.io/docs/campaigns).
83
+ - **A first working call:** https://app2.signalhouse.io/docs/getting-started.
84
84
  - **Current fees and balance:** the billing endpoints, read live, per group.
@@ -76,8 +76,7 @@ cost".
76
76
  ## Where to look it up
77
77
 
78
78
  - **Search filters, NPA/NXX lookup, friendly names, per-number inbound webhook URLs, and the
79
- release semantics:** the internal Number Management Guide
80
- (`documentation/dev-documentation/06-number-management.md`).
79
+ release semantics:** the Numbers reference, https://app2.signalhouse.io/docs/numbers.
81
80
  - **Current fees:** `GET /billing/fees/:groupId`.
82
81
  - **Current balance before a bulk buy:** the wallet endpoints under `/billing/wallet`.
83
82
  - **Whether a number can send:** its campaign's state, not the number's. See `signalhouse-10dlc`.
@@ -4,8 +4,9 @@ description: >-
4
4
  Getting a Signal House integration authenticated and wired: login vs service-user tokens, what a
5
5
  token is scoped to, SDK initialization, and why webhooks come before the first write.
6
6
  TRIGGER when the user is starting a Signal House integration, asks how to authenticate, asks for
7
- an API key or token, is choosing a base URL, is installing or initializing the SDK, is getting a
8
- 401 or 403, or asks why their token stopped working.
7
+ an API key or token, is choosing a base URL, is installing or initializing the SDK, is connecting
8
+ Claude, Cursor or another AI client to Signal House (MCP), is getting a 401 or 403, or asks why
9
+ their token stopped working.
9
10
  SKIP for message sending, 10DLC registration, number purchase and webhook event semantics, which
10
11
  have their own skills. This one ends at "the first authenticated call succeeds".
11
12
  ---
@@ -25,8 +26,8 @@ Authentication guide or the environment rather than quoting a number to a custom
25
26
 
26
27
  **A service user cannot be bootstrapped from nothing.** `POST /user/serviceuser` needs an existing
27
28
  group and a token that is already privileged enough to mint one. The first credential in a new
28
- integration comes from a human logging in or from Signal House provisioning it, not from an API call
29
- made with no credential.
29
+ integration comes from a human logging in (including approving an OAuth connection, below) or from
30
+ Signal House provisioning it, not from an API call made with no credential.
30
31
 
31
32
  **`role: "signalhouse_api"` is not available to customers.** The two roles `POST /user/serviceuser`
32
33
  accepts are `api` (customer key, scoped to one `groupId`) and `signalhouse_api` (internal, requires
@@ -62,6 +63,33 @@ on it. See `signalhouse-10dlc`.
62
63
  without webhooks discovers outcomes by polling, burns rate limit doing it, and still finds out
63
64
  late. See `signalhouse-webhooks`.
64
65
 
66
+ ## Connecting an AI client (MCP)
67
+
68
+ Signal House runs a hosted MCP server at `https://mcp.signalhouse.io/mcp`. It holds no credentials
69
+ of its own: every call is forwarded to the API with the caller's own token, so an agent can do
70
+ exactly what that token can do and nothing more.
71
+
72
+ - **Claude (web, Desktop, mobile): connect with OAuth.** Settings > Connectors > Add custom
73
+ connector, paste the URL, leave the client ID and secret blank, then log in to Signal House and
74
+ approve. No API key is copied anywhere. This is the path to recommend to a person.
75
+ - **Clients configured by file or command (Claude Code, Cursor and similar):** send an API key as a
76
+ bearer header, e.g.
77
+ `claude mcp add --transport http signalhouse https://mcp.signalhouse.io/mcp --header "Authorization: Bearer <API key>"`.
78
+ Claude Code can also do the OAuth flow: add it without the header and authenticate from `/mcp`.
79
+
80
+ What an OAuth connection can and cannot do:
81
+
82
+ - **It acts as the user who approved it, with that user's full role.** Scopes are not enforced, so
83
+ do not tell a customer a connection is "read-only" or limited to part of the account.
84
+ - **It cannot mint credentials or manage the login.** Routes under `/user` and `/auth` refuse a
85
+ connector token with a **403**, so an agent on an OAuth connection cannot create service users or
86
+ API keys. A 403 there is by design, not a bug to work around; create keys in the portal.
87
+ - **It is revoked by the user**, in the portal under Settings > Security > Connected Applications.
88
+ After that the client has to reconnect.
89
+
90
+ For an unattended server integration, the rules above still hold: use a service-user API key, not
91
+ an OAuth connection, which exists for a person working through an AI client.
92
+
65
93
  ## Reading the response
66
94
 
67
95
  The SDK returns an **envelope**, and most failures arrive inside it rather than as an exception:
@@ -82,10 +110,12 @@ current shape from the API reference rather than assuming it matches a sibling e
82
110
 
83
111
  ## Where to look it up
84
112
 
85
- - **Auth flows, service users, the full role table, token practice:** the internal Authentication
86
- guide (`documentation/dev-documentation/02-authentication.md`).
87
- - **First working call end to end:** the Quickstart (`01-quickstart.md`).
88
- - **Envelope shapes, error classes and status codes:** the Error Handling guide (`08-error-handling.md`).
89
- - **Backoff, batching and pagination:** the Rate Limits guide (`09-rate-limits.md`).
90
- - **What a role is allowed to do:** the role table in the Authentication guide. Roles are checked
91
- server-side, so a plan that assumes more access than the token has fails at the call, not earlier.
113
+ - **Auth flows, service users, the full role table, token practice:** the Authentication reference,
114
+ https://app2.signalhouse.io/docs/authentication.
115
+ - **The OAuth connection flow:** https://app2.signalhouse.io/docs/oauth.
116
+ - **First working call end to end:** https://app2.signalhouse.io/docs/getting-started.
117
+ - **Envelope shapes, error classes and status codes:** https://app2.signalhouse.io/docs/error-handling.
118
+ - **Backoff, batching and pagination:** https://app2.signalhouse.io/docs/rate-limits.
119
+ - **What a role is allowed to do:** the role table in the Authentication reference. Roles are
120
+ checked server-side, so a plan that assumes more access than the token has fails at the call, not
121
+ earlier.
@@ -73,9 +73,9 @@ is a 400, not an unbounded query. The API refuses rather than scanning.
73
73
 
74
74
  ## Where to look it up
75
75
 
76
- - **Every send parameter, the full status table and delivery receipts:** the internal Sending
77
- Messages guide (`documentation/dev-documentation/05-sending-messages.md`).
76
+ - **Every send parameter, message statuses and delivery receipts:** the Messages reference,
77
+ https://app2.signalhouse.io/docs/messages.
78
78
  - **Current per-group fees:** `GET /billing/fees/:groupId`.
79
- - **Failure codes and what is retryable:** the Error Handling guide (`08-error-handling.md`).
80
- - **Backoff and batching:** the Rate Limits guide (`09-rate-limits.md`).
79
+ - **Failure codes and what is retryable:** https://app2.signalhouse.io/docs/error-handling.
80
+ - **Backoff and batching:** https://app2.signalhouse.io/docs/rate-limits.
81
81
  - **Why a number cannot send yet:** `signalhouse-10dlc`.
@@ -12,71 +12,90 @@ description: >-
12
12
 
13
13
  # Signal House webhooks
14
14
 
15
- ## What cannot happen
15
+ ## What a receiver can verify
16
16
 
17
- **A receiver cannot verify that a webhook came from Signal House.** Payloads are **not signed**.
18
- There is no HMAC, no signature header, and no request timestamp. This is verified in the
19
- backend, not assumed.
17
+ **Deliveries to a webhook endpoint are signed.** Every endpoint created with `POST /webhook` gets a
18
+ signing secret, returned in plaintext **exactly once**, as `signingSecret` in that create response.
19
+ Every later read reports only `hasSigningSecret: true|false`. Store the secret at creation; nothing
20
+ can hand it back, and a lost secret means creating a new endpoint.
20
21
 
21
- The `authType` setting (`NONE`, `BASIC`, `API_KEY`, `OAUTH2`) authenticates *Signal House to the
22
- receiver's endpoint*. It is the opposite direction from a signature and gives the receiver
23
- nothing to check. Even with `BASIC` or `API_KEY`, the credential is a bearer secret sent *to*
24
- the receiver: it proves nothing about payload integrity and any captured request can be replayed.
22
+ A signed delivery carries:
25
23
 
26
- Practical consequences a receiver has to design around:
24
+ | Header | Value |
25
+ |---|---|
26
+ | `X-SignalHouse-Signature` | hex HMAC-SHA256, keyed with the signing secret, over `${timestamp}.${rawBody}` |
27
+ | `X-SignalHouse-Timestamp` | the `timestamp` in that string: Unix time in **milliseconds** |
28
+ | `X-SignalHouse-Delivery-Id` | a UUID for this event to this endpoint, the same on every retry |
29
+
30
+ A correct verifier:
31
+
32
+ 1. **Uses the raw request body**, byte for byte, before any JSON parsing. Re-serializing a parsed
33
+ body changes whitespace and key order and the signature stops matching.
34
+ 2. **Recomputes the HMAC and compares in constant time.** The signature is bare hex, no prefix.
35
+ 3. **Rejects a stale timestamp.** The timestamp is inside the signed string, so a captured request
36
+ cannot be replayed with a fresh one. Pick a window (five minutes is common) and remember the
37
+ header is milliseconds, not seconds.
38
+ 4. **Rejects an unsigned request to an endpoint that has a secret.** Otherwise a forger simply
39
+ leaves the headers off.
40
+
41
+ What is still **not** verifiable:
42
+
43
+ - **Endpoints created before signing shipped (2026-09-03) are sent unsigned.** They report
44
+ `hasSigningSecret: false`, and there is no way to add a secret to an existing endpoint. To get
45
+ signing, create a new endpoint, store its secret, then delete the old one.
46
+ - **Per-number inbound URLs are never signed** (see below).
27
47
 
28
- - **Do not treat an inbound webhook as authenticated.** Anyone who learns the URL can post to it.
29
- With `authType: NONE` there is nothing stopping them at all.
30
- - **Never take a state-changing action on webhook content alone** where the action is
31
- irreversible or financial. Treat the webhook as a *hint that something changed*, then read the
32
- authoritative state back from the API before acting on it.
33
- - **Do not put secrets in the webhook URL** as a substitute for verification. It ends up in
34
- proxy logs, browser history and support screenshots.
48
+ For anything unsigned, the old rules still apply: do not treat the request as authenticated, and
49
+ never take an irreversible or financial action on webhook content alone. Read the authoritative
50
+ state back from the API first. Do not put secrets in a webhook URL as a substitute for
51
+ verification; URLs end up in proxy logs and support screenshots.
35
52
 
36
- > ⚠️ Tracked as **SHGHL-2944**. Delete this section when signing ships. A workaround left in a
37
- > skill becomes permanent.
53
+ The `authType` setting (`NONE`, `BASIC`, `API_KEY`, `OAUTH2`) is a different thing: it
54
+ authenticates *Signal House to the receiver's endpoint*. The signature is what proves origin and
55
+ integrity. `authType` is a second layer, not a substitute.
38
56
 
39
57
  ## Retry behaviour, and when an event is gone
40
58
 
41
- Delivery is a **direct HTTP POST with a 3-second timeout**, retried **3 times, 500 ms apart, with
42
- no backoff and no jitter**. It is not queued, so there is no queue-level redelivery behind it.
43
- Verified against the send path, which takes the retry defaults at every call site.
59
+ Delivery is a **direct HTTP POST with a 3-second timeout**, made up to **3 times in total**. The
60
+ waits between attempts back off exponentially with jitter: about 500 ms, then about 1 second
61
+ (each up to 25% longer). It is not queued, so there is no queue-level redelivery behind it. Each
62
+ attempt is signed afresh with a new timestamp and carries the **same** delivery id.
63
+
64
+ What gets retried:
65
+
66
+ - **Retried:** no response at all (network error, DNS failure, timeout), any **5xx**, and **429**.
67
+ - **Not retried:** every other **4xx**. A receiver that answers 400, 401, 403 or 404 gets the
68
+ event once. So return a 4xx only for a request you will never accept (a bad signature, say), and
69
+ a 5xx or 429 for anything transient.
44
70
 
45
71
  Two numbers matter, and they are different things:
46
72
 
47
73
  - **A receiver has 3 seconds to respond.** Exceed it and the attempt is a failure, even if the
48
74
  work eventually succeeds on your side.
49
- - **The whole delivery window is about 10 seconds** worst case, three attempts that can each
50
- burn the full timeout, plus 500 ms between them.
51
-
52
- - A receiver that is down, restarting, or rate-limited for even a minute **loses the event
53
- permanently**. There is no dead-letter queue and no replay endpoint.
54
- - **4xx responses are retried too.** The HTTP client throws on any non-2xx and the retry
55
- predicate accepts everything, so a receiver returning 400 gets the same doomed request three
56
- times.
75
+ - **The whole delivery window is about 11 seconds** worst case: three attempts that can each burn
76
+ the full timeout, plus the two waits.
77
+
78
+ - A receiver that is down, restarting, or rate-limited for longer than that **loses the push for
79
+ that event**. There is no dead-letter queue and no replay endpoint.
57
80
  - A slow endpoint that exceeds 3 seconds and *then* completes its work has been recorded as a
58
- failure and will be sent the **same event up to three times**, with no delivery id to key
59
- idempotency on. This is the most likely duplicate scenario in practice.
81
+ failure and will be sent the **same event again**. Dedupe on `X-SignalHouse-Delivery-Id`.
60
82
 
61
83
  So a correct receiver:
62
84
 
63
85
  1. **Responds inside 3 seconds, and does the work afterwards.** Acknowledge, enqueue, process out
64
86
  of band. Any processing done before responding is spending a budget you do not control, and
65
87
  blowing it converts a successful handler into a duplicate delivery.
66
- 2. **Is idempotent on the event's own identifiers** (message id, campaign id and status),
67
- because there is no delivery id to dedupe on.
88
+ 2. **Is idempotent on `X-SignalHouse-Delivery-Id`**, which every delivery carries, signed or not,
89
+ including per-number inbound URLs.
68
90
  3. **Reconciles against `GET /notification`.** This is the important one. Every event that would
69
- trigger a webhook also writes a **notification record, before endpoints are even looked up and
70
- regardless of whether delivery succeeds**. It carries the same `{timestamp, event, identifier,
71
- metaData}` shape and is filterable by group, status and event type.
91
+ trigger a group webhook also writes a **notification record, created before endpoints are even
92
+ looked up and independent of whether delivery succeeds**. It carries the same `{timestamp,
93
+ event, identifier, metaData}` shape and is filterable by group, status and event type.
72
94
 
73
95
  That makes it the durable record and the webhook merely the push. A receiver that must not
74
96
  miss anything polls it to catch up after any outage, which is the only reliable recovery,
75
97
  given there is no replay endpoint.
76
98
 
77
- > ⚠️ Also tracked as **SHGHL-2944**. If the retry policy changes, these numbers are wrong and
78
- > this section must be rewritten from the code, not adjusted from memory.
79
-
80
99
  ## Setting one up
81
100
 
82
101
  Webhooks are created per group, and numbers can additionally carry their own inbound URL.
@@ -90,27 +109,28 @@ Webhooks are created per group, and numbers can additionally carry their own inb
90
109
  | `API_KEY` | `key` holds the key; `secret` optional |
91
110
  | `OAUTH2` | `secret` is the access token sent as the Authorization value; `key` is required at create but unused in outgoing headers |
92
111
 
93
- `NONE` is the right default only for an endpoint that is already unreachable from the public
94
- internet. For anything else, pick a real `authType`. It is not verification, but it stops
95
- casual forgery.
112
+ Verify the signature on every endpoint that has one. On top of that, `NONE` is fine for an
113
+ endpoint that is already unreachable from the public internet; for anything else a real `authType`
114
+ is cheap defense in depth.
96
115
 
97
116
  **The table above does not cover per-number inbound URLs.** A number can carry
98
117
  `primaryInboundWebhookUrl` and `secondaryInboundWebhookUrl`, and inbound messages are posted to
99
- both. That path sends **no auth headers at all**, whatever `authType` is configured elsewhere,
100
- it is always unauthenticated. Do not assume configuring auth on a group webhook protects the
101
- per-number ones; it does not. Treat those URLs as fully public and put nothing behind them that
102
- matters without an independent check.
118
+ both. That path sends **no auth headers and no signature**, whatever is configured elsewhere: it has
119
+ no endpoint record to hold a secret, so it is always unauthenticated. It does carry
120
+ `X-SignalHouse-Delivery-Id`. Do not assume configuring a group webhook protects the per-number
121
+ ones; it does not. Treat those URLs as fully public and put nothing behind them that matters
122
+ without an independent check. If a number's inbound traffic needs verifying, route it through a
123
+ signed group endpoint instead.
103
124
 
104
125
  ## Where to look it up
105
126
 
106
- - **Event types and payload shape:** the internal Webhooks Guide
107
- (`documentation/dev-documentation/04-webhooks-guide.md`). The event list changes independently
108
- of this skill; read it there rather than reciting it here. Note the guide does **not** document
109
- the per-number `primaryInboundWebhookUrl` / `secondaryInboundWebhookUrl` fields described above.
127
+ - **Event types, payload shape and the signing scheme:** the Webhooks reference,
128
+ https://app2.signalhouse.io/docs/webhooks. The event list changes independently of this skill;
129
+ read it there rather than reciting it here.
110
130
  - **Whether an event actually happened:** `GET /notification`
111
- (`documentation/dev-documentation/03-api-reference/13-notifications.md`). The notification
112
- record is written independently of delivery, so it is the record and the webhook is the push.
113
- - **Whether a delivery failed:** you largely cannot, from the outside or the inside. Failed group
114
- and per-number deliveries are swallowed without a server-side log, so support may have no trace
115
- of a delivery you never received. Do not design a process that depends on us being able to
116
- confirm one.
131
+ (https://app2.signalhouse.io/docs/notifications). The notification record is written
132
+ independently of delivery, so it is the record and the webhook is the push.
133
+ - **Whether a delivery failed:** Signal House logs every failed delivery with the group, the
134
+ endpoint, the event and the HTTP status your endpoint returned (or none, for a timeout), so
135
+ support can answer "did you get a 401 from us". There is still no API for a customer to list
136
+ failed deliveries or replay one, so do not design a process that depends on it.
@@ -29,6 +29,7 @@ import { Landings } from "./domains/Landings.js";
29
29
  import { Messages } from "./domains/Messages.js";
30
30
  import { Numbers } from "./domains/Numbers.js";
31
31
  import { Onboarding } from "./domains/Onboarding.js";
32
+ import { Dnc } from "./domains/Dnc.js";
32
33
  import { Shortlinks } from "./domains/Shortlinks.js";
33
34
  import { Subgroups } from "./domains/Subgroups.js";
34
35
  import { Subscriptions } from "./domains/Subscriptions.js";
@@ -73,6 +74,7 @@ export class SignalHouseSDK {
73
74
  this.messages = new Messages(client, multipartClient, this.enableAdmin);
74
75
  this.numbers = new Numbers(client, multipartClient, this.enableAdmin);
75
76
  this.onboarding = new Onboarding(client, this.enableAdmin);
77
+ this.dnc = new Dnc(client, this.enableAdmin);
76
78
  this.shortlinks = new Shortlinks(client, this.enableAdmin);
77
79
  this.subgroups = new Subgroups(client, this.enableAdmin);
78
80
  this.subscriptions = new Subscriptions(client, this.enableAdmin);
@@ -0,0 +1,168 @@
1
+ /** @typedef {import('../SignalHouseSDK.js').RequestOptions} RequestOptions */
2
+ /**
3
+ * @template T
4
+ * @typedef {import('../SignalHouseSDK.js').SdkResponse<T>} SdkResponse
5
+ */
6
+
7
+ /**
8
+ * Do Not Contact domain: the attested "Disable Automated Opt-Out" account setting. Signal House
9
+ * suppresses sends to recipients who opted out of the sending campaign by default; an account may
10
+ * disable that only by having an officer read and e-sign the Opt-Out Handling Attestation inside a
11
+ * Signal House app: the portal (a customer login signs for its own account) or one of our apps running
12
+ * as signalhouse_api (GHL, Shopify), which signs for the account it names on behalf of the customer user
13
+ * it authenticated. Every method therefore lives under the admin namespace (present only when the SDK is
14
+ * created with `enableAdmin: true`), and the API refuses the customer "api" (API key) role on the signing
15
+ * routes, so attestation can never be performed by a third-party integration.
16
+ */
17
+ export class Dnc {
18
+ constructor(client, enableAdmin) {
19
+ this.client = client;
20
+ this.enableAdmin = enableAdmin;
21
+
22
+ // Hidden Admin namespace INSIDE the domain
23
+ if (enableAdmin) {
24
+ this.admin = {
25
+ /**
26
+ * Get the current Opt-Out Handling Attestation text for the session's account with every placeholder
27
+ * filled (account values, the session's email and role, blanks in the signature block for what only
28
+ * signing supplies), plus the version and template hash a signature must echo back. The app must
29
+ * render this text; it never ships its own copy.
30
+ * @async
31
+ * @roles admin, developer, billing (portal logins); signalhouse_* with groupId
32
+ * @param {Object} [params] - Optional parameters
33
+ * @param {string} [params.groupId] - Group to render for (apps and staff; customers always get their active group)
34
+ * @param {RequestOptions} [params.options] - Additional options for the request
35
+ * @returns {Promise<SdkResponse<any>>} - `{ version, templateSha256, text, checkboxText, groupId, accountName, customerLegalName }`
36
+ */
37
+ getCurrentAttestation: async ({ groupId, options = {} } = {}) => {
38
+ const queryString = this.client._getQueryString({ groupId });
39
+ return this.client(`/dnc/attestation/current${queryString}`, { method: "GET", ...options });
40
+ },
41
+
42
+ /**
43
+ * Sign the attestation and disable automated opt-out suppression for the account and every subgroup,
44
+ * effective within about a minute on every API instance. Two callers: a portal login signs for its own
45
+ * account and the signer identity is taken from the session; a Signal House app running as
46
+ * signalhouse_api (GHL, Shopify) signs for the `groupId` it names and supplies the identity of the
47
+ * customer user it authenticated (`signerEmail`, optional `signerUserId`, `signedVia`, and the end
48
+ * user's `signerIpAddress` / `signerUserAgent`, which otherwise default to the app server's). Resolves
49
+ * 201 when this call created the record and 200 with the existing record on a repeat or concurrent
50
+ * call. A stale version or hash is refused with 409 and `details.code === "ATTESTATION_VERSION_MISMATCH"`;
51
+ * an unchecked box or blank field with 400. Human staff roles and customer API-key sessions are refused.
52
+ * @async
53
+ * @roles admin, developer, billing (portal logins); signalhouse_api (apps, with groupId)
54
+ * @param {Object} params
55
+ * @param {string} params.version - The version returned by getCurrentAttestation
56
+ * @param {string} params.templateSha256 - The template hash returned by getCurrentAttestation
57
+ * @param {boolean} params.checkboxAcknowledged - Must be true
58
+ * @param {boolean} params.scrolledToEnd - Must be true
59
+ * @param {string} params.customerLegalName - Company legal name
60
+ * @param {string} params.signerName - Signer's typed full legal name (the electronic signature)
61
+ * @param {string} params.signerTitle - Signer's title
62
+ * @param {string} [params.groupId] - App callers: the account being attested
63
+ * @param {string} [params.signerEmail] - App callers (required): the signer's email as authenticated by the app
64
+ * @param {string} [params.signerUserId] - App callers: the app's id for the signer
65
+ * @param {string} [params.signedVia] - App callers: the app name, e.g. "ghl" or "shopify"
66
+ * @param {string} [params.signerIpAddress] - App callers: the end user's IP address
67
+ * @param {string} [params.signerUserAgent] - App callers: the end user's user agent
68
+ * @param {RequestOptions} [params.options] - Additional options for the request
69
+ * @throws {Error} Throws an error if a required parameter is missing
70
+ * @returns {Promise<SdkResponse<any>>} - `{ attestation, created }`
71
+ */
72
+ signAttestation: async ({ version, templateSha256, checkboxAcknowledged, scrolledToEnd, customerLegalName, signerName, signerTitle, groupId, signerEmail, signerUserId, signedVia, signerIpAddress, signerUserAgent, options = {} }) => {
73
+ this.client._require({ version, templateSha256, customerLegalName, signerName, signerTitle });
74
+ const appFields = Object.fromEntries(Object.entries({ groupId, signerEmail, signerUserId, signedVia, signerIpAddress, signerUserAgent }).filter(([, value]) => value !== undefined));
75
+ return this.client(`/dnc/attestation`, {
76
+ method: "POST",
77
+ body: { version, templateSha256, checkboxAcknowledged, scrolledToEnd, customerLegalName, signerName, signerTitle, ...appFields },
78
+ ...options,
79
+ });
80
+ },
81
+
82
+ /**
83
+ * Revoke the active attestation and re-enable automated opt-out suppression, effective within about a
84
+ * minute: a portal login for its own account, or an app (signalhouse_api) for the `groupId` it names.
85
+ * The record is retained as "revoked". 404 when nothing is active.
86
+ * @async
87
+ * @roles admin, developer, billing (portal logins); signalhouse_api (apps, with groupId)
88
+ * @param {Object} [params] - Optional parameters
89
+ * @param {string} [params.groupId] - App callers: the account
90
+ * @param {RequestOptions} [params.options] - Additional options for the request
91
+ * @returns {Promise<SdkResponse<any>>} - `{ attestation }`
92
+ */
93
+ revokeAttestation: async ({ groupId, options = {} } = {}) => {
94
+ return this.client(`/dnc/attestation/revoke`, { method: "POST", ...(groupId ? { body: { groupId } } : {}), ...options });
95
+ },
96
+
97
+ /**
98
+ * Read an account's attestation state: whether automated opt-out is disabled, the active record
99
+ * (full stored text included), the full history, and whether the active record predates the
100
+ * current template and must be re-signed by `resignDeadline`.
101
+ * @async
102
+ * @roles admin, developer, billing (portal logins); signalhouse_* with groupId
103
+ * @param {Object} [params] - Optional parameters
104
+ * @param {string} [params.groupId] - Group to read (apps and staff; customers always get their active group)
105
+ * @param {RequestOptions} [params.options] - Additional options for the request
106
+ * @returns {Promise<SdkResponse<any>>} - `{ groupId, automatedOptOutDisabled, currentVersion, currentTemplateSha256, requiresResign, resignDeadline, active, history }`
107
+ */
108
+ getAttestation: async ({ groupId, options = {} } = {}) => {
109
+ const queryString = this.client._getQueryString({ groupId });
110
+ return this.client(`/dnc/attestation${queryString}`, { method: "GET", ...options });
111
+ },
112
+
113
+ /**
114
+ * List opt-out attestations across every account, newest first, with each group's trailing
115
+ * would-have-suppressed counts (7 and 30 days, recorded while attested) and 7-day opt-out count.
116
+ * The full attestation text is omitted from list rows. Staff-only.
117
+ * @async
118
+ * @roles signalhouse
119
+ * @param {Object} [params] - Optional parameters
120
+ * @param {("active"|"revoked"|"superseded")} [params.status] - Filter by status
121
+ * @param {string} [params.groupId] - Filter by group
122
+ * @param {number} [params.page] - Page number (default 1)
123
+ * @param {number} [params.limit] - Rows per page (default 50, max 500)
124
+ * @param {RequestOptions} [params.options] - Additional options for the request
125
+ * @returns {Promise<SdkResponse<any>>} - `{ rows, totalCount, page, limit }`
126
+ */
127
+ listAttestations: async ({ status, groupId, page, limit, options = {} } = {}) => {
128
+ const queryString = this.client._getQueryString({ status, groupId, page, limit });
129
+ return this.client(`/dnc/attestations${queryString}`, { method: "GET", ...options });
130
+ },
131
+
132
+ /**
133
+ * Read one attestation with its stored text, hashes, signer identity, IP and user agent, plus
134
+ * the group's full attestation history. Staff-only.
135
+ * @async
136
+ * @roles signalhouse
137
+ * @param {Object} params
138
+ * @param {string} params.id - The attestation id
139
+ * @param {RequestOptions} [params.options] - Additional options for the request
140
+ * @throws {Error} Throws an error if the id parameter is missing
141
+ * @returns {Promise<SdkResponse<any>>} - `{ attestation, history }`
142
+ */
143
+ getAttestationById: async ({ id, options = {} }) => {
144
+ this.client._require({ id });
145
+ return this.client(`/dnc/attestations/${encodeURIComponent(id)}`, { method: "GET", ...options });
146
+ },
147
+
148
+ /**
149
+ * Revoke an active attestation with a reason and re-enable automated opt-out for the account.
150
+ * Emails the signer and account owner. There is no staff path that creates an attestation. Staff-only.
151
+ * @async
152
+ * @roles signalhouse
153
+ * @param {Object} params
154
+ * @param {string} params.id - The attestation id
155
+ * @param {("carrier_complaint"|"regulator_inquiry"|"legal_request"|"excessive_opt_out_rate"|"customer_request"|"other")} params.reasonCode - Why it is being revoked
156
+ * @param {string} params.reason - Free-text reason shown to the customer
157
+ * @param {RequestOptions} [params.options] - Additional options for the request
158
+ * @throws {Error} Throws an error if a required parameter is missing
159
+ * @returns {Promise<SdkResponse<any>>} - `{ attestation }` (status "revoked", revokedByType "signalhouse_admin")
160
+ */
161
+ revokeAttestationById: async ({ id, reasonCode, reason, options = {} }) => {
162
+ this.client._require({ id, reasonCode, reason });
163
+ return this.client(`/dnc/attestations/${encodeURIComponent(id)}/revoke`, { method: "POST", body: { reasonCode, reason }, ...options });
164
+ },
165
+ };
166
+ }
167
+ }
168
+ }
@@ -12,7 +12,8 @@ export class Messages {
12
12
  }
13
13
 
14
14
  /**
15
- * Estimate Canadian SMS/MMS retail cost without sending or charging.
15
+ * Estimate Canadian SMS/MMS retail cost; no charge or send. Requires a Ready Canadian sender and Canadian or US
16
+ * recipients, and is unavailable unless Canada is enabled for the environment.
16
17
  * @async
17
18
  * @param {Object} params - Sender, up to 100 recipients, messageBody and request options.
18
19
  * @returns {Promise<SdkResponse<any>>} - Per-recipient rates and total in microdollars; unknown carriers use the upper fallback.
@@ -38,6 +39,7 @@ export class Messages {
38
39
  * @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.
39
40
  * @param {string} [params.carrier] - Filter messages by the carrier used for sending
40
41
  * @param {string|string[]} [params.sentimentLabel] - Filter messages by sentiment bucket. Accepts a single value or an array (e.g. ["positive", "negative"]). Allowed values: positive, neutral, negative. Applied as the sentimentScore range the ±25 label threshold produces, so unscored messages match no bucket and outbound messages (never scored) are excluded.
42
+ * @param {string|string[]} [params.errorCode] - Filter FAILED messages by Signal House error code (single value or array). "OUT" is the campaign opt-out, displayed as RECIPIENT_OPTED_OUT. Carrier codes are not filterable here; they live inside successOrFailureReason.
41
43
  * @param {string|string[]} [params.phoneNumber] - Filter messages by a business number on either side of the message. Accepts a single value or an array; OR'd with campaignId/brandId when matchAny is true
42
44
  * @param {string} [params.senderPhoneNumber] - Filter messages by the sender's 5-6 digit Short Code or 10+ digit phone number
43
45
  * @param {string} [params.recipientPhoneNumber] - Filter messages by the recipient's phone number
@@ -69,6 +71,7 @@ export class Messages {
69
71
  matchAny,
70
72
  carrier,
71
73
  sentimentLabel,
74
+ errorCode,
72
75
  startDate,
73
76
  endDate,
74
77
  sortField,
@@ -78,7 +81,7 @@ export class Messages {
78
81
  dateBounds,
79
82
  options = {},
80
83
  }) {
81
- const filters = { id, campaignId, brandId, subgroupId, groupId, phoneNumber, senderPhoneNumber, recipientPhoneNumber, status, direction, messageType, channel, matchAny, carrier, sentimentLabel, startDate, endDate, sortField, sortOrder, page, limit, dateBounds };
84
+ const filters = { id, campaignId, brandId, subgroupId, groupId, phoneNumber, senderPhoneNumber, recipientPhoneNumber, status, direction, messageType, channel, matchAny, carrier, sentimentLabel, errorCode, startDate, endDate, sortField, sortOrder, page, limit, dateBounds };
82
85
  const queryString = this.client._getQueryString(filters);
83
86
  return this.client(`/message${queryString}`, { method: "GET", ...options });
84
87
  }
@@ -114,7 +117,7 @@ export class Messages {
114
117
  * @param {string|string[]} [params.campaignId] - Filter analytics by the ID of the associated campaign. Accepts a single value, an array, or a comma-separated string.
115
118
  * @param {string|string[]} [params.phoneNumber] - Filter analytics by the phone number involved in the messages. Accepts a single value, an array, or a comma-separated string.
116
119
  * @param {string|string[]} [params.carrier] - Filter analytics by the carrier used for sending. Accepts a single value, an array, or a comma-separated string.
117
- * @param {string|string[]} [params.carrierFamily] - Filter analytics by the recipient's resolved carrier family (Default, ATT, TMobile, Verizon, USCellular, GoogleVoice, ClearSky, Interop, RogueMobile, P2P). Accepts a single value, an array, or a comma-separated string. Only accepted where the environment serves hourly analytics; elsewhere any value is rejected with a 400 -- omit the parameter unless it is wanted.
120
+ * @param {string|string[]} [params.carrierFamily] - Filter analytics by the recipient's resolved carrier family (Default, ATT, TMobile, Verizon, USCellular, GoogleVoice, ClearSky, Interop, RogueMobile, P2P, Standard, IceWireless). Accepts a single value, an array, or a comma-separated string. Only accepted where the environment serves hourly analytics; elsewhere any value is rejected with a 400 -- omit the parameter unless it is wanted.
118
121
  * @param {string} [params.startDate] - ISO-8601 date or timestamp; bound per dateBounds: a bare date is always that whole UTC day (inclusive); a timestamp is widened to its whole UTC day in "day" mode and bound as the instant it names, in whatever offset it was written, in "exact" mode
119
122
  * @param {string} [params.endDate] - ISO-8601 date or timestamp; bound per dateBounds: a bare date is always that whole UTC day (inclusive); a timestamp is widened to its whole UTC day in "day" mode and bound as the instant it names, in whatever offset it was written, in "exact" mode. In "exact" mode day buckets align to the UTC offset this bound carries, so a client that sends its own midnights gets its own calendar dates back
120
123
  * @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.
@@ -148,7 +151,7 @@ export class Messages {
148
151
  * @param {string|string[]} [params.campaignId] - Filter analytics by the ID of the associated campaign. Accepts a single value, an array, or a comma-separated string.
149
152
  * @param {string|string[]} [params.phoneNumber] - Filter analytics by the phone number involved in the messages. Accepts a single value, an array, or a comma-separated string.
150
153
  * @param {string|string[]} [params.carrier] - Filter analytics by the carrier used for sending. Accepts a single value, an array, or a comma-separated string.
151
- * @param {string|string[]} [params.carrierFamily] - Filter analytics by the recipient's resolved carrier family (Default, ATT, TMobile, Verizon, USCellular, GoogleVoice, ClearSky, Interop, RogueMobile, P2P). Accepts a single value, an array, or a comma-separated string. Only accepted where the environment serves hourly analytics; elsewhere any value is rejected with a 400 -- omit the parameter unless it is wanted.
154
+ * @param {string|string[]} [params.carrierFamily] - Filter analytics by the recipient's resolved carrier family (Default, ATT, TMobile, Verizon, USCellular, GoogleVoice, ClearSky, Interop, RogueMobile, P2P, Standard, IceWireless). Accepts a single value, an array, or a comma-separated string. Only accepted where the environment serves hourly analytics; elsewhere any value is rejected with a 400 -- omit the parameter unless it is wanted.
152
155
  * @param {string} [params.startDate] - ISO-8601 date or timestamp. It is bound per dateBounds: a bare date is always that whole UTC day (inclusive); a timestamp is widened to its whole UTC day in "day" mode and bound as the instant it names, in whatever offset it was written, in "exact" mode, then floored to the start of its hour at either granularity, because the hourly cube is keyed by hour and a mid-hour start would drop that hour's bucket.
153
156
  * @param {string} [params.endDate] - ISO-8601 date or timestamp. At day granularity it is bound per dateBounds: a bare date is always that whole UTC day (inclusive); a timestamp is widened to its whole UTC day in "day" mode and bound as the instant it names, in whatever offset it was written, in "exact" mode. In "exact" mode day buckets align to the UTC offset this bound carries, so a client that sends its own midnights gets its own calendar dates back; at hour granularity it is honoured as given.
154
157
  * @param {(string|string[])} [params.messageType] - Message type(s) the SENTIMENT figures are scoped to: SMS, MMS or P2P (single value, array, or comma-separated string). Omit for all scored types. Does NOT narrow the message counts, which are always returned split per type (smsInbound/mmsInbound/p2pInbound) and have no filterable type dimension.
@@ -226,7 +229,7 @@ export class Messages {
226
229
  * @param {string|string[]} [params.campaignId]
227
230
  * @param {string|string[]} [params.phoneNumber]
228
231
  * @param {string|string[]} [params.carrier]
229
- * @param {string|string[]} [params.carrierFamily] - Resolved carrier family of the recipient (Default, ATT, TMobile, Verizon, USCellular, GoogleVoice, ClearSky, Interop, RogueMobile, P2P); single value, array, or comma-separated. Only accepted where the environment serves hourly analytics; elsewhere any value is rejected with a 400 -- omit the parameter unless it is wanted.
232
+ * @param {string|string[]} [params.carrierFamily] - Resolved carrier family of the recipient (Default, ATT, TMobile, Verizon, USCellular, GoogleVoice, ClearSky, Interop, RogueMobile, P2P, Standard, IceWireless); single value, array, or comma-separated. Only accepted where the environment serves hourly analytics; elsewhere any value is rejected with a 400 -- omit the parameter unless it is wanted.
230
233
  * @param {string} [params.startDate]
231
234
  * @param {string} [params.endDate]
232
235
  * @param {number} [params.page=1]
@@ -256,7 +259,7 @@ export class Messages {
256
259
  * @param {string|string[]} [params.campaignId]
257
260
  * @param {string|string[]} [params.phoneNumber]
258
261
  * @param {string|string[]} [params.carrier]
259
- * @param {string|string[]} [params.carrierFamily] - Resolved carrier family of the recipient (Default, ATT, TMobile, Verizon, USCellular, GoogleVoice, ClearSky, Interop, RogueMobile, P2P); single value, array, or comma-separated. Only accepted where the environment serves hourly analytics; elsewhere any value is rejected with a 400 -- omit the parameter unless it is wanted.
262
+ * @param {string|string[]} [params.carrierFamily] - Resolved carrier family of the recipient (Default, ATT, TMobile, Verizon, USCellular, GoogleVoice, ClearSky, Interop, RogueMobile, P2P, Standard, IceWireless); single value, array, or comma-separated. Only accepted where the environment serves hourly analytics; elsewhere any value is rejected with a 400 -- omit the parameter unless it is wanted.
260
263
  * @param {string} [params.startDate]
261
264
  * @param {string} [params.endDate]
262
265
  * @param {number} [params.page=1]
@@ -288,7 +291,7 @@ export class Messages {
288
291
  * @param {string|string[]} [params.channel] - Filter by channel (tenDLC, virtualLongCode, 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.
289
292
  * @param {("day"|"exact")} [params.dateBounds] - Which contract binds startDate/endDate. "day": each bound is widened to its whole UTC day. "exact": a timestamp is bound as the instant it names and a bare date is its whole UTC day. Omitted: "day".
290
293
  * @param {RequestOptions} [params.options] - Additional options for the request
291
- * @returns {Promise<SdkResponse<any>>} - A promise that resolves to the DNC analytics data (totals, byDate, byPhoneNumber, byCarrier, byKeyword). byKeyword breaks opt-outs down by the normalized keyword that revoked consent ({ optOutKeyword, total, sms, mms }); opt-outs recorded before keyword capture shipped appear as a single optOutKeyword: null bucket ("not recorded"). The distinct keyword set is open-ended (campaigns register their own keywords beyond the mandatory floor) — render top-N plus Other, never a fixed list.
294
+ * @returns {Promise<SdkResponse<any>>} - A promise that resolves to the DNC analytics data (totals, byDate, byPhoneNumber, bySenderNumber, byCampaign, bySubgroup, byType, byCarrier, byKeyword). bySenderNumber ({ senderPhoneNumber, total, sms, mms }) attributes each opt-out to the first business number that received it, so it is unchanged by later campaign moves and START replies. byCampaign ({ campaignId, ... }) counts campaign-scoped opt-outs only, by the campaign at the time of the STOP; subgroup-scoped opt-outs (non-US recipients) are never attributed to a campaign and appear under bySubgroup ({ subgroupId, ... }) instead. byType ({ messageType, total }) splits SMS/MMS. byKeyword breaks opt-outs down by the normalized keyword that revoked consent ({ optOutKeyword, total, sms, mms }); opt-outs recorded before keyword capture shipped appear as a single optOutKeyword: null bucket ("not recorded"). The distinct keyword set is open-ended (campaigns register their own keywords beyond the mandatory floor) — render top-N plus Other, never a fixed list.
292
295
  */
293
296
  async getDncAnalytics({ groupId, subgroupId, brandId, campaignId, phoneNumber, carrier, startDate, endDate, channel, dateBounds, options = {} }) {
294
297
  const filters = { groupId, subgroupId, brandId, campaignId, phoneNumber, carrier, startDate, endDate, channel, dateBounds };
@@ -316,7 +319,7 @@ export class Messages {
316
319
  * @param {string|string[]} [params.channel] - Filter by channel (tenDLC, virtualLongCode, 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.
317
320
  * @param {("day"|"exact")} [params.dateBounds] - Which contract binds startDate/endDate. "day": each bound is widened to its whole UTC day. "exact": a timestamp is bound as the instant it names and a bare date is its whole UTC day. Omitted: "day".
318
321
  * @param {RequestOptions} [params.options] - Additional options for the request
319
- * @returns {Promise<SdkResponse<any>>} - A promise that resolves to the paginated DNC records. Each record carries optOutKeyword — the normalized keyword that revoked consent (e.g. "stop", "opt out"; a repeat opt-out records the most recent word), null for opt-outs recorded before keyword capture shipped — and carrierFamily, the resolved carrier family of the recipient number (Default, ATT, TMobile, Verizon, USCellular, GoogleVoice, ClearSky, Interop, RogueMobile), copied from the inbound message that triggered the opt-out.
322
+ * @returns {Promise<SdkResponse<any>>} - A promise that resolves to the paginated DNC records. Each record carries senderPhoneNumber — the first business number that received the opt-out (set once, never changed by a later STOP or a campaign move; the same value as phoneNumber) — optOutKeyword — the normalized keyword that revoked consent (e.g. "stop", "opt out"; a repeat opt-out records the most recent word), null for opt-outs recorded before keyword capture shipped — and carrierFamily, the resolved carrier family of the recipient number (Default, ATT, TMobile, Verizon, USCellular, GoogleVoice, ClearSky, Interop, RogueMobile, Standard, IceWireless), copied from the inbound message that triggered the opt-out. campaignId is null on subgroup-scoped records (non-US recipients).
320
323
  */
321
324
  async getDncRecords({ groupId, subgroupId, brandId, campaignId, phoneNumber, carrier, startDate, endDate, page, limit, sortField, sortOrder, channel, dateBounds, options = {} }) {
322
325
  const filters = { groupId, subgroupId, brandId, campaignId, phoneNumber, carrier, startDate, endDate, page, limit, sortField, sortOrder, channel, dateBounds };
@@ -134,7 +134,7 @@ export class Numbers {
134
134
  * @param {boolean} [params.voiceEnabled] - Filter for phone numbers that are voice enabled
135
135
  * @param {string} [params.country] - Filter by country code (e.g., "US")
136
136
  * @param {string} [params.state] - Filter by state code (e.g., "CA")
137
- * @param {string} [params.city] - City prefix; requires country US/CA and state/province.
137
+ * @param {string} [params.city] - City prefix; requires country US and state.
138
138
  * @param {string} [params.npa] - Filter by NPA (area code)
139
139
  * @param {string} [params.nxx] - Filter by NXX (central office code) - the first three digits of the local phone number after the area code
140
140
  * @param {string} [params.phoneNumber] - Filter by phone number
@@ -172,7 +172,7 @@ export class Numbers {
172
172
  * @param {Object} params - The parameters for purchasing phone numbers
173
173
  * @param {string[]} params.phoneNumbers - The list of phone numbers to purchase
174
174
  * @param {string} params.subgroupId - The ID of the subgroup to assign the purchased phone numbers to
175
- * @param {'US'|'CA'} [params.country='US'] - ISO-2 purchase country. Toll-Free always uses the shared registry.
175
+ * @param {string} [params.country='US'] - ISO-2 purchase country; US.
176
176
  * @param {RequestOptions} [params.options] - Additional options for the request
177
177
  * @throws {Error} Throws an error if the phoneNumbers or subgroupId parameter is missing
178
178
  * @returns {Promise<SdkResponse<any>>} A promise that resolves to `{ message }` once the request is queued.
@@ -193,7 +193,7 @@ export class Numbers {
193
193
  * @param {Object} params - The parameters for purchasing toll-free numbers
194
194
  * @param {number} params.quantity - The number of toll-free numbers to purchase (1-10)
195
195
  * @param {string} params.subgroupId - The subgroup the purchased numbers are assigned to
196
- * @param {'US'|'CA'} [params.country='US'] - ISO-2 purchase country. Toll-Free always uses the shared registry.
196
+ * @param {string} [params.country='US'] - ISO-2 purchase country; US.
197
197
  * @param {RequestOptions} [params.options] - Additional options for the request
198
198
  * @throws {Error} Throws an error if the quantity or subgroupId parameter is missing
199
199
  * @returns {Promise<SdkResponse<any>>} A promise that resolves to `{ message, orderId }` once the request is queued.
@@ -26,9 +26,11 @@
26
26
 
27
27
  /**
28
28
  * @typedef {Object} CreateServiceUserData
29
- * @property {string} groupId - The ID of the group to which the service user belongs (required)
29
+ * @property {string} groupId - The ID of the group to which the service user belongs (required for customer API keys)
30
30
  * @property {string} name - The name of the service user (required)
31
- * @property {string} role - The role of the service user (admin, developer, billing, user) (required)
31
+ * @property {string} role - The role of the service user: "api" for a customer API key (required)
32
+ * @property {string[]} [allowedIps] - Only accept this API key from these IPv4/IPv6 addresses or CIDR ranges (max 100). Omit or pass [] for no IP restriction. Customer API keys only.
33
+ * @property {string[]} [allowedCountries] - Only accept this API key from these countries, as ISO 3166-1 alpha-2 codes such as "US" (max 250). Omit or pass [] for no country restriction. Customer API keys only.
32
34
  */
33
35
 
34
36
  /**
@@ -47,6 +49,8 @@
47
49
  * @property {string} [country] - The country of the user's address (optional)
48
50
  * @property {string} [postalCode] - The postal code of the user's address (optional)
49
51
  * @property {string} [status] - The status of the user (e.g., "active", "inactive")
52
+ * @property {string[]} [allowedIps] - Customer API keys only: replace the key's allowed IPv4/IPv6 addresses or CIDR ranges ([] removes the restriction). Requires an interactive admin or developer session; an API key cannot change its own restrictions.
53
+ * @property {string[]} [allowedCountries] - Customer API keys only: replace the key's allowed ISO 3166-1 alpha-2 country codes ([] removes the restriction). Requires an interactive admin or developer session.
50
54
  */
51
55
 
52
56
  export class Users {
@@ -37,8 +37,10 @@ export class Webhooks {
37
37
  * plaintext HMAC-SHA256 secret used to sign every delivery to this endpoint, sent as the
38
38
  * `X-SignalHouse-Signature` header alongside `X-SignalHouse-Timestamp` (the signed string is
39
39
  * `${timestamp}.${rawRequestBody}`) and `X-SignalHouse-Delivery-Id` (stable across retries of the
40
- * same event, for receiver-side dedup). Store this secret now: it is never returned again by any
41
- * read (SHGHL-2944).
40
+ * same event, for receiver-side dedup), plus `X-SignalHouse-Signature-V1` (`t=<ms>,v1=<hex>[,v1=<hex>]`,
41
+ * every currently valid signature). Store this secret now: it is never returned again by any read
42
+ * (SHGHL-2944). If it's lost, call `rotateWebhookSecret`. Verify deliveries with
43
+ * `verifyWebhookSignature` from `@signalhousellc/sdk/webhooks` (Node only).
42
44
  * @async
43
45
  * @roles api, admin, developer
44
46
  * @param {Object} params - The parameters for creating a webhook
@@ -53,9 +55,11 @@ export class Webhooks {
53
55
  }
54
56
 
55
57
  /**
56
- * Update an existing webhook with the specified data. Like every other read of a webhook, the
57
- * response carries `hasSigningSecret` (boolean), never the signing secret itself — see
58
- * `createWebhook` (SHGHL-2944).
58
+ * Update an existing webhook with the specified data. `updateData` may include `name`, `url`,
59
+ * `subscribedEvents` and (SHGHL-3486) `authType`, `apiHeaderPrefix` and `credentials` (`{ key, secret? }`)
60
+ * to change the endpoint's own credentials in place: credentials are required when switching to an auth
61
+ * type that needs them, and omitted credentials keep the stored ones. Like every read of a webhook, the
62
+ * response carries `hasSigningSecret` and `hasCredentials` (booleans), never a secret or credential.
59
63
  * @async
60
64
  * @roles api, admin, developer
61
65
  * @param {Object} params - The parameters for updating a webhook
@@ -71,6 +75,28 @@ export class Webhooks {
71
75
  return this.client(`/webhook/${safeId}`, { method: "PUT", body: updateData, ...options });
72
76
  }
73
77
 
78
+ /**
79
+ * Rotate a webhook's signing secret, or give a webhook created before signing existed its first one
80
+ * (SHGHL-3486). The response includes the new plaintext `signingSecret`, returned this one time only.
81
+ * The replaced secret keeps working for `graceSeconds` (default 86400, max 604800, 0 = stop now):
82
+ * during that window each delivery's `X-SignalHouse-Signature-V1` header carries a signature for both,
83
+ * and `previousSecretExpiresAt` in the response says when the old one stops. Not available to OAuth
84
+ * connector tokens.
85
+ * @async
86
+ * @roles api, admin, developer
87
+ * @param {Object} params - The parameters for rotating the secret
88
+ * @param {string} params.id - The ID of the webhook
89
+ * @param {number} [params.graceSeconds] - How long the replaced secret stays valid
90
+ * @param {RequestOptions} [params.options] - Additional options for the request
91
+ * @throws {Error} Throws an error if the id parameter is missing
92
+ * @returns {Promise<SdkResponse<any>>} The webhook, including the new plaintext `signingSecret` (this one time only)
93
+ */
94
+ async rotateWebhookSecret({ id, graceSeconds, options = {} }) {
95
+ this.client._require({ id });
96
+ const safeId = encodeURIComponent(id);
97
+ return this.client(`/webhook/${safeId}/rotate-secret`, { method: "POST", body: graceSeconds === undefined ? {} : { graceSeconds }, ...options });
98
+ }
99
+
74
100
  /**
75
101
  * Delete a webhook by its ID (mark as inactive)
76
102
  * @async
@@ -0,0 +1,66 @@
1
+ import crypto from "node:crypto";
2
+
3
+ // Node-only helpers for receiving Signal House webhooks (SHGHL-3486). Kept out of the main entry point,
4
+ // which also runs in browsers, because it depends on node:crypto. Import from "@signalhousellc/sdk/webhooks".
5
+
6
+ const DEFAULT_TOLERANCE_MS = 5 * 60 * 1000;
7
+
8
+ /**
9
+ * Verify that a webhook delivery came from Signal House and is recent.
10
+ *
11
+ * Checks `X-SignalHouse-Signature-V1` (`t=<ms>,v1=<hex>[,v1=<hex>]`, which carries every currently valid
12
+ * signature during a secret rotation) when present, otherwise the original `X-SignalHouse-Signature` +
13
+ * `X-SignalHouse-Timestamp` pair. Each signature is a hex HMAC-SHA256 of `${timestamp}.${rawBody}`. Comparisons
14
+ * are constant-time.
15
+ *
16
+ * @param {Object} params
17
+ * @param {string|Buffer} params.rawBody - The exact request body as received, before any JSON parsing.
18
+ * @param {Object} params.headers - Request headers (any casing; e.g. Express `req.headers`).
19
+ * @param {string} [params.secret] - Your endpoint's signing secret.
20
+ * @param {string[]} [params.secrets] - Several secrets to accept, e.g. old and new while you roll over.
21
+ * @param {number} [params.toleranceMs=300000] - Reject deliveries whose timestamp is further than this from now.
22
+ * @param {number} [params.now=Date.now()] - Current time in ms (for tests).
23
+ * @returns {{valid: boolean, reason?: string}} - `valid` true when a signature matches within the time window;
24
+ * otherwise `reason` is one of `no_secret`, `no_signature`, `bad_timestamp`, `stale_timestamp`, `bad_signature`.
25
+ */
26
+ export function verifyWebhookSignature({ rawBody, headers, secret, secrets, toleranceMs = DEFAULT_TOLERANCE_MS, now = Date.now() }) {
27
+ const candidates = (secrets ?? (secret ? [secret] : [])).filter(Boolean);
28
+ if (candidates.length === 0) return { valid: false, reason: "no_secret" };
29
+
30
+ const header = (name) => {
31
+ if (!headers) return undefined;
32
+ const match = Object.keys(headers).find((key) => key.toLowerCase() === name);
33
+ const value = match === undefined ? undefined : headers[match];
34
+ return Array.isArray(value) ? value[0] : value;
35
+ };
36
+
37
+ let timestamp;
38
+ let signatures = [];
39
+ const v1 = header("x-signalhouse-signature-v1");
40
+ if (v1) {
41
+ const parts = String(v1).split(",").map((part) => part.trim());
42
+ timestamp = parts.find((part) => part.startsWith("t="))?.slice(2);
43
+ signatures = parts.filter((part) => part.startsWith("v1=")).map((part) => part.slice(3)).filter(Boolean);
44
+ }
45
+ // No usable V1 (absent, or mangled by an intermediary): fall back to the original header pair
46
+ if (signatures.length === 0) {
47
+ timestamp = header("x-signalhouse-timestamp");
48
+ const legacy = header("x-signalhouse-signature");
49
+ signatures = legacy ? [String(legacy)] : [];
50
+ }
51
+ if (signatures.length === 0) return { valid: false, reason: "no_signature" };
52
+
53
+ const sentAt = Number(timestamp);
54
+ if (!timestamp || !Number.isFinite(sentAt)) return { valid: false, reason: "bad_timestamp" };
55
+ if (Math.abs(now - sentAt) > toleranceMs) return { valid: false, reason: "stale_timestamp" };
56
+
57
+ const body = Buffer.isBuffer(rawBody) ? rawBody.toString("utf8") : String(rawBody ?? "");
58
+ for (const candidate of candidates) {
59
+ const expected = Buffer.from(crypto.createHmac("sha256", candidate).update(`${timestamp}.${body}`).digest("hex"), "utf8");
60
+ for (const signature of signatures) {
61
+ const given = Buffer.from(signature, "utf8");
62
+ if (given.length === expected.length && crypto.timingSafeEqual(given, expected)) return { valid: true };
63
+ }
64
+ }
65
+ return { valid: false, reason: "bad_signature" };
66
+ }