@voxiness/n8n-nodes-voxisms 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 VoxiSMS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # @voxiness/n8n-nodes-voxisms
2
+
3
+ n8n community nodes for [VoxiSMS](https://voxisms.com): send SMS through the VoxiSMS ecosystem, and start workflows on inbound SMS.
4
+
5
+ Two nodes:
6
+
7
+ - **VoxiSMS** (action node): sends an SMS.
8
+ - **VoxiSMS Trigger** (trigger node, "New Inbound SMS"): starts the workflow when your VoxiSMS number receives an inbound SMS.
9
+
10
+ ## Installation
11
+
12
+ ### n8n GUI (Community Nodes)
13
+
14
+ **Settings → Community Nodes → Install**, then enter the package name:
15
+
16
+ ```
17
+ @voxiness/n8n-nodes-voxisms
18
+ ```
19
+
20
+ ### Self-hosted (npm)
21
+
22
+ ```bash
23
+ npm install @voxiness/n8n-nodes-voxisms
24
+ ```
25
+
26
+ Restart n8n after installing.
27
+
28
+ ## Credentials
29
+
30
+ Both nodes use the **VoxiSMS API** credential type: `Customer ID` + `Token`. Get both from the "Link your phone" page in your VoxiPlan dashboard: https://app.voxiplan.com/voxisms
31
+
32
+ - **Customer ID**: the phone number you registered, without the leading `+` (e.g. `33639980000`). Listed under "Register your number".
33
+ - **Token**: listed under "Activate your token". The same token you paste into the VoxiSMS Android app during setup.
34
+
35
+ Use the credential's "Test" button to verify your setup. It performs a signed, read-only status check and does **not** send an SMS.
36
+
37
+ ## Nodes
38
+
39
+ ### VoxiSMS (Send SMS)
40
+
41
+ | Field | Required | Description |
42
+ |---|---|---|
43
+ | Recipient | yes | Phone number in E.164 format (e.g. `+15551234567`) |
44
+ | Message | yes | The SMS text content |
45
+ | Message ID | no | Optional custom message id; a UUID is generated automatically if left empty |
46
+
47
+ On success, the node outputs `{ messageId, sqsMessageId }`.
48
+
49
+ ### VoxiSMS Trigger (New Inbound SMS)
50
+
51
+ Instant (webhook) trigger, no configurable parameters. Activating the workflow registers a webhook subscription with VoxiSMS using n8n's auto-generated webhook URL; deactivating the workflow removes it.
52
+
53
+ Each verified delivery emits one item shaped as an `InboundMessageEvent`:
54
+
55
+ ```json
56
+ {
57
+ "version": "1",
58
+ "event": "inbound_message.received",
59
+ "eventId": "evt_0f2f8f0e-924a-5b8b-8352-6fc04c31ddbd",
60
+ "messageId": "69d44359-1c82-4f84-a9a0-45c89bb4ce98",
61
+ "customerId": "33639980000",
62
+ "occurredAt": "2026-07-14T14:42:18.123Z",
63
+ "messageType": "sms",
64
+ "fromNumber": "+32470987654",
65
+ "toNumber": "+33639980000",
66
+ "content": "Yes, that works for me"
67
+ }
68
+ ```
69
+
70
+ Notes:
71
+
72
+ - Every delivery is HMAC-verified (`X-VoxiSMS-Signature` / `X-VoxiSMS-Timestamp`) before the workflow fires. An unsigned request is dropped quietly; a present-but-invalid signature is rejected and never fires the workflow.
73
+ - Deliveries are **at-least-once**: VoxiSMS may retry a delivery, and n8n does not de-duplicate incoming webhook payloads. Use `eventId` to de-duplicate downstream if your workflow needs exactly-once processing.
74
+
75
+ ## Building from source
76
+
77
+ ```bash
78
+ npm install # install dependencies
79
+ npm run build # compile TypeScript and copy node icons into dist/
80
+ npm test # run the unit tests
81
+ ```
82
+
83
+ If your n8n instance restricts outbound network access, allow `api.voxisms.com`.
84
+
85
+ ## License
86
+
87
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,7 @@
1
+ import type { ICredentialType, INodeProperties } from 'n8n-workflow';
2
+ export declare class VoxiSmsApi implements ICredentialType {
3
+ name: string;
4
+ displayName: string;
5
+ documentationUrl: string;
6
+ properties: INodeProperties[];
7
+ }
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.VoxiSmsApi = void 0;
4
+ // VoxiSMS credential definition.
5
+ //
6
+ // Auth is per-user HMAC (VoxiSMS v2): every request is signed with the user's secret
7
+ // token — there is NO shared api-key, and neither `customerId` nor `secretKey` is ever
8
+ // sent in the request body. The three signed headers (customer-id, timestamp, signature)
9
+ // are built per-request by `buildSignedHeaders` in `nodes/shared/signing.ts`.
10
+ //
11
+ // We deliberately do NOT declare a generic `authenticate` block here. n8n's generic auth
12
+ // can only inject static headers/query params; our signature depends on the HTTP method,
13
+ // the base-path-stripped route, a fresh timestamp, AND the exact request-body bytes. That
14
+ // is request-specific, so signing happens inside each node (the Send node and the
15
+ // connection test) where those values are known — never as a one-size-fits-all header.
16
+ class VoxiSmsApi {
17
+ constructor() {
18
+ this.name = 'voxiSmsApi';
19
+ this.displayName = 'VoxiSMS API';
20
+ // Points at the VoxiPlan dashboard page where users find both fields below.
21
+ this.documentationUrl = 'https://app.voxiplan.com/voxisms';
22
+ this.properties = [
23
+ {
24
+ // The customer identifier is the registered phone number. We normalize away a
25
+ // pasted leading "+" / spaces at sign time (normalizeCustomerId), but ask users
26
+ // to enter it without the "+" to match what they see in the dashboard.
27
+ displayName: 'Customer ID',
28
+ name: 'customerId',
29
+ type: 'string',
30
+ default: '',
31
+ required: true,
32
+ placeholder: '33639980000',
33
+ description: 'Enter the phone number you registered, without the leading "+" (e.g. 33639980000). You can find it under "Register your number" on the "Link your phone" page (https://app.voxiplan.com/voxisms) in your VoxiPlan dashboard.',
34
+ },
35
+ {
36
+ // Labeled "Token" (not "Secret Key") to match the wording in the VoxiPlan
37
+ // dashboard and the VoxiSMS Android app. Masked in the UI via password option.
38
+ displayName: 'Token',
39
+ name: 'secretKey',
40
+ type: 'string',
41
+ typeOptions: { password: true },
42
+ default: '',
43
+ required: true,
44
+ description: 'Copy the token shown under "Activate your token" on the "Link your phone" page (https://app.voxiplan.com/voxisms) in your VoxiPlan dashboard — it\'s the same token you paste from the VoxiSMS Android app during setup.',
45
+ },
46
+ ];
47
+ }
48
+ }
49
+ exports.VoxiSmsApi = VoxiSmsApi;
@@ -0,0 +1,10 @@
1
+ import type { ICredentialTestFunctions, ICredentialsDecrypted, IExecuteFunctions, INodeCredentialTestResult, INodeExecutionData, INodeType, INodeTypeDescription } from 'n8n-workflow';
2
+ export declare class VoxiSms implements INodeType {
3
+ description: INodeTypeDescription;
4
+ methods: {
5
+ credentialTest: {
6
+ voxiSmsApiTest(this: ICredentialTestFunctions, credential: ICredentialsDecrypted): Promise<INodeCredentialTestResult>;
7
+ };
8
+ };
9
+ execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]>;
10
+ }
@@ -0,0 +1,256 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.VoxiSms = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const n8n_workflow_1 = require("n8n-workflow");
6
+ const signing_1 = require("../shared/signing");
7
+ // Full URLs (the `/v2` base path lives here, in the URL). The SIGNED path is the
8
+ // base-path-stripped route constant (ENQUEUE_PATH / STATUS_PATH) — API Gateway strips
9
+ // `/v2` before the Lambda signs, so the URL path and the signed path differ on purpose.
10
+ const ENQUEUE_URL = 'https://api.voxisms.com/v2/enqueue-message';
11
+ const STATUS_URL = 'https://api.voxisms.com/v2/user/status';
12
+ // Default delivery-status callback baked into every send (matches the Zapier action).
13
+ const WEBHOOK_URL = 'https://app.voxiplan.com/webhooks/sms/events';
14
+ // Best-effort JSON parse of an HTTP response body. n8n's httpRequest usually parses a
15
+ // JSON response into an object already, but a string can come back (e.g. non-JSON
16
+ // content-type); handle both so the caller always gets a plain object.
17
+ function toObject(body) {
18
+ if (body && typeof body === 'object') {
19
+ return body;
20
+ }
21
+ if (typeof body === 'string') {
22
+ try {
23
+ return JSON.parse(body);
24
+ }
25
+ catch {
26
+ return { response: body };
27
+ }
28
+ }
29
+ return {};
30
+ }
31
+ // Extract the server's `{ error }` message from a v2 error body, if present.
32
+ function serverErrorMessage(body) {
33
+ const parsed = toObject(body);
34
+ return typeof parsed.error === 'string' ? parsed.error : undefined;
35
+ }
36
+ class VoxiSms {
37
+ constructor() {
38
+ this.description = {
39
+ displayName: 'VoxiSMS',
40
+ name: 'voxiSms',
41
+ icon: 'file:voxisms.svg',
42
+ group: ['output'],
43
+ version: 1,
44
+ description: 'Send an SMS via VoxiSMS',
45
+ defaults: {
46
+ name: 'VoxiSMS',
47
+ },
48
+ inputs: ['main'],
49
+ outputs: ['main'],
50
+ credentials: [
51
+ {
52
+ name: 'voxiSmsApi',
53
+ required: true,
54
+ // Wires the "Test" button in the credential modal to voxiSmsApiTest below.
55
+ testedBy: 'voxiSmsApiTest',
56
+ },
57
+ ],
58
+ // Flat, single-action node (like the Zapier "Send SMS" action) — no
59
+ // resource/operation dropdowns for a package that does exactly one thing.
60
+ properties: [
61
+ {
62
+ displayName: 'Recipient',
63
+ name: 'recipient',
64
+ type: 'string',
65
+ default: '',
66
+ required: true,
67
+ placeholder: '+15551234567',
68
+ description: 'Phone number in E.164 format (e.g. +15551234567)',
69
+ },
70
+ {
71
+ displayName: 'Message',
72
+ name: 'message',
73
+ type: 'string',
74
+ typeOptions: { rows: 4 },
75
+ default: '',
76
+ required: true,
77
+ description: 'The SMS message content',
78
+ },
79
+ {
80
+ displayName: 'Message ID',
81
+ name: 'id',
82
+ type: 'string',
83
+ default: '',
84
+ description: 'Optional custom message ID. A UUID will be generated automatically if left empty.',
85
+ },
86
+ ],
87
+ };
88
+ this.methods = {
89
+ credentialTest: {
90
+ // Side-effect-free credential check: a signed GET to the read-only status
91
+ // endpoint (does NOT send an SMS). Success is HTTP 200; the endpoint returns
92
+ // distinct failure codes we map to the same friendly messages as the Zapier
93
+ // integration's authentication test.
94
+ async voxiSmsApiTest(credential) {
95
+ var _a, _b, _c;
96
+ const data = ((_a = credential.data) !== null && _a !== void 0 ? _a : {});
97
+ // Normalize ONCE and reuse for both the signed canonical and the customer-id
98
+ // header (buildSignedHeaders uses this same value for both, so they can't
99
+ // disagree).
100
+ const customerId = (0, signing_1.normalizeCustomerId)((_b = data.customerId) !== null && _b !== void 0 ? _b : '');
101
+ const secretKey = (_c = data.secretKey) !== null && _c !== void 0 ? _c : '';
102
+ // Body-less GET: bodyString defaults to '' inside buildSignedHeaders, so the
103
+ // canonical ends in a trailing '\n' + empty segment, matching the server's
104
+ // `body: b""`.
105
+ const headers = (0, signing_1.buildSignedHeaders)({
106
+ method: 'GET',
107
+ path: signing_1.STATUS_PATH,
108
+ customerId,
109
+ secretKey,
110
+ });
111
+ let statusCode;
112
+ try {
113
+ // Legacy request helper (the only one exposed to credential tests). We
114
+ // inspect the status ourselves — resolveWithFullResponse gives us the
115
+ // status, simple:false stops it throwing on non-2xx.
116
+ const response = await this.helpers.request({
117
+ method: 'GET',
118
+ uri: STATUS_URL,
119
+ headers,
120
+ resolveWithFullResponse: true,
121
+ simple: false,
122
+ });
123
+ statusCode = response.statusCode;
124
+ }
125
+ catch (error) {
126
+ // A transport-level failure (DNS, TLS, timeout) — not an HTTP status.
127
+ return {
128
+ status: 'Error',
129
+ message: `Could not reach VoxiSMS: ${error.message}`,
130
+ };
131
+ }
132
+ if (statusCode === 200) {
133
+ return { status: 'OK', message: 'Connection successful!' };
134
+ }
135
+ if (statusCode === 403) {
136
+ return {
137
+ status: 'Error',
138
+ message: 'Authentication failed. Check your Token, and make sure your system clock is accurate (requests must be within 5 minutes of server time).',
139
+ };
140
+ }
141
+ if (statusCode === 404) {
142
+ return {
143
+ status: 'Error',
144
+ message: 'Customer ID not found. Check the phone number you registered.',
145
+ };
146
+ }
147
+ if (statusCode === 400) {
148
+ return {
149
+ status: 'Error',
150
+ message: 'This account is not fully set up yet. Contact VoxiSMS support.',
151
+ };
152
+ }
153
+ return {
154
+ status: 'Error',
155
+ message: 'Could not verify credentials. Check your Customer ID and Token.',
156
+ };
157
+ },
158
+ },
159
+ };
160
+ }
161
+ async execute() {
162
+ var _a, _b;
163
+ const items = this.getInputData();
164
+ const returnData = [];
165
+ // Credentials are the same for every item; fetch and normalize ONCE.
166
+ const credentials = (await this.getCredentials('voxiSmsApi'));
167
+ const customerId = (0, signing_1.normalizeCustomerId)((_a = credentials.customerId) !== null && _a !== void 0 ? _a : '');
168
+ const secretKey = (_b = credentials.secretKey) !== null && _b !== void 0 ? _b : '';
169
+ for (let i = 0; i < items.length; i++) {
170
+ try {
171
+ const recipient = this.getNodeParameter('recipient', i);
172
+ const message = this.getNodeParameter('message', i);
173
+ const id = this.getNodeParameter('id', i, '');
174
+ // CRITICAL INVARIANT — sign-once / send-same-bytes:
175
+ // Serialize the body EXACTLY ONCE with JSON.stringify, sign THAT string, then
176
+ // send THAT string verbatim on the wire. Re-serializing (or letting the HTTP
177
+ // client re-encode an object) could reorder keys or change spacing and break
178
+ // the signature. That's why `body` below is the string, NOT an object, and we
179
+ // set Content-Type ourselves (json:true is intentionally NOT used).
180
+ const bodyString = JSON.stringify({
181
+ id: id || (0, crypto_1.randomUUID)(),
182
+ recipient,
183
+ message,
184
+ webhookUrl: WEBHOOK_URL,
185
+ source: 'n8n',
186
+ });
187
+ const headers = {
188
+ ...(0, signing_1.buildSignedHeaders)({
189
+ method: 'POST',
190
+ path: signing_1.ENQUEUE_PATH,
191
+ customerId,
192
+ secretKey,
193
+ bodyString,
194
+ }),
195
+ 'Content-Type': 'application/json',
196
+ };
197
+ // returnFullResponse: read the status ourselves for friendly errors.
198
+ // ignoreHttpStatusErrors: don't throw on non-2xx — we map them below.
199
+ // body is the already-serialized string; httpRequest sends it verbatim.
200
+ const response = await this.helpers.httpRequest({
201
+ method: 'POST',
202
+ url: ENQUEUE_URL,
203
+ headers,
204
+ body: bodyString,
205
+ returnFullResponse: true,
206
+ ignoreHttpStatusErrors: true,
207
+ });
208
+ const statusCode = response.statusCode;
209
+ const body = response.body;
210
+ if (statusCode >= 200 && statusCode < 300) {
211
+ // Success body: { messageId, sqsMessageId }.
212
+ returnData.push({ json: toObject(body), pairedItem: { item: i } });
213
+ continue;
214
+ }
215
+ // Map the v2 API's real HTTP error statuses to friendly messages, mirroring
216
+ // the Zapier middleware. The v2 error body shape is { error }.
217
+ const serverError = serverErrorMessage(body);
218
+ let friendly;
219
+ if (statusCode === 401) {
220
+ friendly =
221
+ 'Authentication failed. Check your Customer ID and Token, and make sure your system clock is accurate (within 5 minutes of server time).';
222
+ }
223
+ else if (statusCode === 400) {
224
+ friendly = serverError || 'Bad request';
225
+ }
226
+ else if (statusCode === 405) {
227
+ friendly = 'Method not allowed';
228
+ }
229
+ else if (statusCode >= 500) {
230
+ friendly = serverError || 'Internal server error';
231
+ }
232
+ else {
233
+ friendly = serverError || `Request failed with status code ${statusCode}`;
234
+ }
235
+ throw new n8n_workflow_1.NodeApiError(this.getNode(), toObject(body), {
236
+ message: friendly,
237
+ httpCode: String(statusCode),
238
+ itemIndex: i,
239
+ });
240
+ }
241
+ catch (error) {
242
+ // Honor "Continue On Fail": emit the error on this item and keep going.
243
+ if (this.continueOnFail()) {
244
+ returnData.push({
245
+ json: { error: error.message },
246
+ pairedItem: { item: i },
247
+ });
248
+ continue;
249
+ }
250
+ throw error;
251
+ }
252
+ }
253
+ return [returnData];
254
+ }
255
+ }
256
+ exports.VoxiSms = VoxiSms;
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 60 60" fill="none">
2
+ <path d="M12 6h36a8 8 0 0 1 8 8v22a8 8 0 0 1-8 8H26L14 54V44h-2a8 8 0 0 1-8-8V14a8 8 0 0 1 8-8Z" fill="#4F46E5"/>
3
+ <circle cx="20" cy="25" r="3.2" fill="#FFFFFF"/>
4
+ <circle cx="30" cy="25" r="3.2" fill="#FFFFFF"/>
5
+ <circle cx="40" cy="25" r="3.2" fill="#FFFFFF"/>
6
+ </svg>
@@ -0,0 +1,12 @@
1
+ import { type IHookFunctions, type INodeType, type INodeTypeDescription, type IWebhookFunctions, type IWebhookResponseData } from 'n8n-workflow';
2
+ export declare class VoxiSmsTrigger implements INodeType {
3
+ description: INodeTypeDescription;
4
+ webhookMethods: {
5
+ default: {
6
+ checkExists(this: IHookFunctions): Promise<boolean>;
7
+ create(this: IHookFunctions): Promise<boolean>;
8
+ delete(this: IHookFunctions): Promise<boolean>;
9
+ };
10
+ };
11
+ webhook(this: IWebhookFunctions): Promise<IWebhookResponseData>;
12
+ }
@@ -0,0 +1,298 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.VoxiSmsTrigger = void 0;
4
+ const n8n_workflow_1 = require("n8n-workflow");
5
+ const signing_1 = require("../shared/signing");
6
+ const verifySignature_1 = require("../shared/verifySignature");
7
+ // Full v2 URLs. The signed canonical uses the base-path-stripped path (SUBSCRIPTIONS_PATH,
8
+ // no `/v2`) because API Gateway strips the `/v2` prefix before the Lambda sees it — the
9
+ // wire URL and the signed path deliberately differ.
10
+ const SUBSCRIPTIONS_URL = 'https://api.voxisms.com/v2/webhook-subscriptions';
11
+ // The single event type this trigger subscribes to (see openapi SubscribeRequest).
12
+ const EVENT_TYPE = 'inbound_message.received';
13
+ // Reads a header case-INsensitively. Express lowercases incoming header names, but we do not
14
+ // want to depend on that: read `x-voxisms-signature` / `x-voxisms-timestamp` defensively so a
15
+ // differently-cased proxy in front of n8n cannot make every delivery look unsigned.
16
+ function getHeaderCI(headers, name) {
17
+ const wanted = name.toLowerCase();
18
+ for (const key of Object.keys(headers)) {
19
+ if (key.toLowerCase() === wanted) {
20
+ return headers[key];
21
+ }
22
+ }
23
+ return undefined;
24
+ }
25
+ class VoxiSmsTrigger {
26
+ constructor() {
27
+ this.description = {
28
+ displayName: 'VoxiSMS Trigger',
29
+ name: 'voxiSmsTrigger',
30
+ icon: 'file:voxisms.svg',
31
+ group: ['trigger'],
32
+ version: 1,
33
+ description: 'Starts the workflow when your VoxiSMS number receives an inbound SMS.',
34
+ defaults: {
35
+ name: 'VoxiSMS Trigger',
36
+ },
37
+ // No user-configurable parameters: the subscription is driven entirely by the
38
+ // credential and the auto-generated webhook URL. Required by INodeTypeDescription.
39
+ properties: [],
40
+ // A trigger has no data inputs; it is fed by an incoming webhook, not an upstream node.
41
+ inputs: [],
42
+ outputs: ['main'],
43
+ credentials: [
44
+ {
45
+ // Credential type owned by the parallel Send-SMS agent; referenced here by name.
46
+ name: 'voxiSmsApi',
47
+ required: true,
48
+ },
49
+ ],
50
+ webhooks: [
51
+ {
52
+ name: 'default',
53
+ httpMethod: 'POST',
54
+ // We ACK the delivery ourselves from inside webhook() so we can control the HTTP
55
+ // status (200 vs 401) per the signature outcome — see the response policy there.
56
+ responseMode: 'onReceived',
57
+ path: 'webhook',
58
+ },
59
+ ],
60
+ };
61
+ // Lifecycle hooks for the subscription REST-hook, run by n8n when the workflow is
62
+ // activated/deactivated. `this` is IHookFunctions here.
63
+ this.webhookMethods = {
64
+ default: {
65
+ // checkExists — n8n asks whether a live subscription already exists so it can skip a
66
+ // redundant create. We treat the presence of a persisted subscriptionId as truth.
67
+ async checkExists() {
68
+ const staticData = this.getWorkflowStaticData('node');
69
+ return staticData.subscriptionId !== undefined && staticData.subscriptionId !== null;
70
+ },
71
+ // create — called when the workflow is activated. Registers our webhook URL with the
72
+ // VoxiSMS API so future inbound SMS are POSTed to it, and stashes both the returned
73
+ // subscriptionId (to unsubscribe later) and signingSecret (to verify each delivery).
74
+ async create() {
75
+ var _a;
76
+ const webhookUrl = this.getNodeWebhookUrl('default');
77
+ if (!webhookUrl) {
78
+ throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'Could not determine the webhook URL for this trigger.');
79
+ }
80
+ const credentials = await this.getCredentials('voxiSmsApi');
81
+ // Normalize ONCE and reuse the same value for both the signed canonical and the
82
+ // `customer-id` header, so they cannot disagree.
83
+ const customerId = (0, signing_1.normalizeCustomerId)(credentials.customerId);
84
+ const secretKey = credentials.secretKey;
85
+ // CRITICAL INVARIANT: serialize the body ONCE, then sign and send that exact
86
+ // string. Never let the HTTP helper re-serialize a parsed object — a re-serialize
87
+ // can reorder keys or change whitespace and break the signature the server checks.
88
+ const bodyString = JSON.stringify({
89
+ targetUrl: webhookUrl,
90
+ eventTypes: [EVENT_TYPE],
91
+ // The API's SubscribeRequest.provider enum is currently only
92
+ // ['zapier','make','generic'] — 'n8n' is rejected with a 400 "unknown provider".
93
+ // Use 'generic' until the backend adds an 'n8n' value (then flip this).
94
+ provider: 'generic',
95
+ });
96
+ const headers = {
97
+ ...(0, signing_1.buildSignedHeaders)({
98
+ method: 'POST',
99
+ path: signing_1.SUBSCRIPTIONS_PATH,
100
+ customerId,
101
+ secretKey,
102
+ bodyString,
103
+ }),
104
+ 'Content-Type': 'application/json',
105
+ };
106
+ const options = {
107
+ method: 'POST',
108
+ url: SUBSCRIPTIONS_URL,
109
+ headers,
110
+ // Pass the pre-serialized string as the body and DO NOT set `json: true`; that
111
+ // keeps the exact signed bytes on the wire (see the invariant above).
112
+ body: bodyString,
113
+ json: false,
114
+ // Inspect the status ourselves so a non-2xx becomes a friendly, mapped error
115
+ // instead of the raw axios-style throw. Mirrors the sibling integrations'
116
+ // error mapping (Zapier middleware.js, Make attach.response.error).
117
+ returnFullResponse: true,
118
+ ignoreHttpStatusErrors: true,
119
+ };
120
+ const response = (await this.helpers.httpRequest(options));
121
+ // The API returns a JSON string when json:false; the helper may still parse it, so
122
+ // tolerate both an already-parsed object and a raw string.
123
+ const parseBody = (raw) => {
124
+ var _a;
125
+ if (typeof raw === 'string') {
126
+ try {
127
+ return JSON.parse(raw);
128
+ }
129
+ catch {
130
+ return {};
131
+ }
132
+ }
133
+ return (_a = raw) !== null && _a !== void 0 ? _a : {};
134
+ };
135
+ const data = parseBody(response.body);
136
+ // Non-2xx: map to the same friendly messages the sibling integrations surface, so
137
+ // the user sees an actionable message rather than a raw HTTP error when they turn
138
+ // the workflow on. The v2 error body shape is `{ error }`.
139
+ if (response.statusCode < 200 || response.statusCode >= 300) {
140
+ const serverError = typeof data.error === 'string' ? data.error : undefined;
141
+ let message;
142
+ if (response.statusCode === 403 || response.statusCode === 401) {
143
+ message =
144
+ 'Authentication failed. Check your Token and make sure your system clock is accurate (requests must be within 5 minutes of server time).';
145
+ }
146
+ else if (response.statusCode === 404) {
147
+ message = 'Customer ID not found. Check the phone number you registered.';
148
+ }
149
+ else if (response.statusCode === 400) {
150
+ // Per the API spec, a subscribe 400 means: invalid JSON body, a non-HTTPS or
151
+ // private/internal targetUrl, or an unknown eventTypes/provider value. By far
152
+ // the most common cause in practice is a webhook URL VoxiSMS cannot accept —
153
+ // e.g. a local n8n without a public HTTPS URL — so say that explicitly.
154
+ message = `VoxiSMS rejected the webhook subscription${serverError ? ` (${serverError})` : ''}. The webhook URL was "${webhookUrl}" — it must be public HTTPS (not localhost/private). For local development start n8n with --tunnel or set WEBHOOK_URL to a public HTTPS tunnel.`;
155
+ }
156
+ else {
157
+ message = `[${response.statusCode}] ${serverError !== null && serverError !== void 0 ? serverError : 'VoxiSMS request failed'}`;
158
+ }
159
+ throw new n8n_workflow_1.NodeApiError(this.getNode(), (_a = data) !== null && _a !== void 0 ? _a : {}, { message, httpCode: String(response.statusCode) });
160
+ }
161
+ if (!data.subscriptionId || !data.signingSecret) {
162
+ throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'VoxiSMS did not return a subscriptionId and signingSecret when creating the webhook subscription.');
163
+ }
164
+ const staticData = this.getWorkflowStaticData('node');
165
+ staticData.subscriptionId = data.subscriptionId;
166
+ // The signingSecret is what verifies every future delivery. Without it we cannot
167
+ // authenticate deliveries, so it MUST be persisted alongside the subscriptionId.
168
+ staticData.signingSecret = data.signingSecret;
169
+ return true;
170
+ },
171
+ // delete — called when the workflow is deactivated. Removes the subscription so the
172
+ // API stops POSTing to a webhook URL that is no longer live, then clears static data.
173
+ async delete() {
174
+ const staticData = this.getWorkflowStaticData('node');
175
+ const subscriptionId = staticData.subscriptionId;
176
+ // Nothing registered (or already cleared) — treat as success, nothing to undo.
177
+ if (!subscriptionId) {
178
+ return true;
179
+ }
180
+ const credentials = await this.getCredentials('voxiSmsApi');
181
+ const customerId = (0, signing_1.normalizeCustomerId)(credentials.customerId);
182
+ const secretKey = credentials.secretKey;
183
+ // The DELETE route is dynamic and the id is part of the signed path
184
+ // (base-path-stripped, no `/v2`). Body is empty, so the canonical ends in a
185
+ // trailing '\n' (buildSignedHeaders default bodyString = '').
186
+ const path = `${signing_1.SUBSCRIPTIONS_PATH}/${subscriptionId}`;
187
+ const headers = (0, signing_1.buildSignedHeaders)({
188
+ method: 'DELETE',
189
+ path,
190
+ customerId,
191
+ secretKey,
192
+ });
193
+ const options = {
194
+ method: 'DELETE',
195
+ url: `${SUBSCRIPTIONS_URL}/${subscriptionId}`,
196
+ headers,
197
+ // A 404 means the subscription is already gone server-side — that is the desired
198
+ // end state, so do not throw on it; ignore HTTP status errors and treat as done.
199
+ ignoreHttpStatusErrors: true,
200
+ };
201
+ try {
202
+ await this.helpers.httpRequest(options);
203
+ }
204
+ catch {
205
+ // Network/other failure while unsubscribing: swallow it so deactivation still
206
+ // clears local state. A stale server-side subscription will simply deliver to a
207
+ // URL that now rejects, which is harmless (and re-activation re-creates one).
208
+ }
209
+ // Always clear both keys so a later checkExists reports "no subscription" and a
210
+ // re-activation creates a fresh one with a fresh signing secret.
211
+ delete staticData.subscriptionId;
212
+ delete staticData.signingSecret;
213
+ return true;
214
+ },
215
+ },
216
+ };
217
+ }
218
+ // webhook — runs for every delivery the API POSTs to our URL. n8n has NOT yet responded to
219
+ // the HTTP request, so this method both decides the HTTP status AND whether to fire the
220
+ // workflow. We mirror the Make webhook's response policy exactly (see communication.json):
221
+ // - signing secret missing → surface loudly (config broken; user must recreate trigger)
222
+ // - reason 'missing' → 200 {ok:false}, DO NOT fire (unsigned noise, drop quietly)
223
+ // - reason invalid|skew|unverifiable → 401, DO NOT fire (present but bad → surface)
224
+ // - ok → 200 {ok:true}, fire with the parsed event JSON (one item)
225
+ async webhook() {
226
+ const req = this.getRequestObject();
227
+ const res = this.getResponseObject();
228
+ // The signature is computed over the EXACT raw request bytes. n8n populates
229
+ // `req.rawBody` (a Buffer) for webhook requests via its raw-body reader middleware;
230
+ // the property is declared on http.IncomingMessage by n8n-workflow's type augmentation.
231
+ // We deliberately do NOT fall back to re-serializing the parsed body (getBodyData()) —
232
+ // a re-serialize reorders keys / changes whitespace and would break the digest by
233
+ // design. If the raw bytes are somehow absent, verifySignature returns 'unverifiable'
234
+ // (a present signature we cannot check) and we surface a 401 rather than trust it.
235
+ const rawBuffer = req.rawBody;
236
+ const rawBody = Buffer.isBuffer(rawBuffer) ? rawBuffer.toString('utf8') : undefined;
237
+ const headers = req.headers;
238
+ const signatureHeader = getHeaderCI(headers, 'x-voxisms-signature');
239
+ const timestampHeader = getHeaderCI(headers, 'x-voxisms-timestamp');
240
+ // Config-drift guard: without a signing secret we can verify nothing, and
241
+ // createHmac('sha256', undefined) would throw a bare TypeError. This means the
242
+ // subscription was created without persisting the secret (or static data was wiped) —
243
+ // surface it loudly so the user recreates the trigger. Do not ACK, do not fire.
244
+ const staticData = this.getWorkflowStaticData('node');
245
+ const signingSecret = staticData.signingSecret;
246
+ if (!signingSecret) {
247
+ throw new n8n_workflow_1.NodeOperationError(this.getNode(), "Cannot verify the inbound SMS delivery: the subscription's signing secret is missing. Please deactivate and reactivate this trigger to recreate the subscription.");
248
+ }
249
+ const result = (0, verifySignature_1.verifySignature)({
250
+ signingSecret,
251
+ rawBody,
252
+ signatureHeader,
253
+ timestampHeader,
254
+ });
255
+ if (!result.ok) {
256
+ if (result.reason === 'missing') {
257
+ // No usable signature header: unsigned noise (health checks, scanners, a stray
258
+ // POST). ACK with 200 {ok:false} so the sender does not retry, but DO NOT fire —
259
+ // returning noWebhookResponse hands the HTTP response to us, and no workflowData
260
+ // means no execution.
261
+ res.status(200).json({ ok: false });
262
+ return { noWebhookResponse: true };
263
+ }
264
+ // Present but invalid / skewed / unverifiable: almost always secret/config drift or a
265
+ // replay attempt. Reject with 401 and DO NOT fire — surfaced via the HTTP status so
266
+ // the caller (and VoxiSMS delivery logs) can see the rejection.
267
+ res.status(401).json({ ok: false, reason: result.reason });
268
+ return { noWebhookResponse: true };
269
+ }
270
+ // Verified. Parse the (now trusted) raw bytes into the InboundMessageEvent object. We
271
+ // parse rawBody ourselves rather than getBodyData() so the emitted item is exactly the
272
+ // bytes we authenticated — no chance of a divergent second parse.
273
+ //
274
+ // Can't-happen-unless-server-bug guard: the signature already verified, so the sender is
275
+ // authentic and the bytes are what VoxiSMS signed — a malformed JSON body here would mean
276
+ // a server-side bug. If we let JSON.parse throw, it would escape webhook(), n8n would 500
277
+ // the delivery, and VoxiSMS would retry the same poison delivery indefinitely. Instead we
278
+ // ACK with 400 {ok:false} (so the sender stops retrying) and DO NOT fire the workflow.
279
+ let event;
280
+ try {
281
+ event = JSON.parse(rawBody);
282
+ }
283
+ catch {
284
+ res.status(400).json({ ok: false, reason: 'malformed' });
285
+ return { noWebhookResponse: true };
286
+ }
287
+ // NOTE: webhook deliveries are AT-LEAST-ONCE and n8n does NOT de-duplicate them. A
288
+ // dispatcher retry carrying the same `eventId` WILL fire this workflow again. We expose
289
+ // `eventId` on the emitted item so downstream steps (or the user) can de-duplicate on it
290
+ // if they need exactly-once semantics.
291
+ res.status(200).json({ ok: true });
292
+ return {
293
+ noWebhookResponse: true,
294
+ workflowData: [this.helpers.returnJsonArray([event])],
295
+ };
296
+ }
297
+ }
298
+ exports.VoxiSmsTrigger = VoxiSmsTrigger;
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 60 60" fill="none">
2
+ <rect x="6" y="10" width="48" height="34" rx="10" fill="#4F46E5"/>
3
+ <path d="M18 44l-2 8 10-6z" fill="#4F46E5"/>
4
+ <path d="M36 20l-9 9m0 0h6m-6 0v-6" stroke="#FFFFFF" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
5
+ </svg>
@@ -0,0 +1,13 @@
1
+ export declare const ENQUEUE_PATH = "/enqueue-message";
2
+ export declare const STATUS_PATH = "/user/status";
3
+ export declare const SUBSCRIPTIONS_PATH = "/webhook-subscriptions";
4
+ export interface SignedHeadersParams {
5
+ method: string;
6
+ path: string;
7
+ customerId: string;
8
+ secretKey: string;
9
+ bodyString?: string;
10
+ timestamp?: string;
11
+ }
12
+ export declare const buildSignedHeaders: ({ method, path, customerId, secretKey, bodyString, timestamp, }: SignedHeadersParams) => Record<string, string>;
13
+ export declare const normalizeCustomerId: (customerId: string) => string;
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeCustomerId = exports.buildSignedHeaders = exports.SUBSCRIPTIONS_PATH = exports.STATUS_PATH = exports.ENQUEUE_PATH = void 0;
4
+ const crypto_1 = require("crypto");
5
+ // Base-path-stripped route constants. API Gateway strips the `/v2` prefix, so these
6
+ // are the exact paths the Lambda signs — NOT the full URL paths.
7
+ exports.ENQUEUE_PATH = '/enqueue-message';
8
+ exports.STATUS_PATH = '/user/status';
9
+ // Webhook-subscription collection path. The DELETE route is dynamic
10
+ // (`/webhook-subscriptions/{subscriptionId}`); build it inline from this constant.
11
+ exports.SUBSCRIPTIONS_PATH = '/webhook-subscriptions';
12
+ // Builds the VoxiSMS HMAC auth headers for a request.
13
+ //
14
+ // The canonical string signed by the server is:
15
+ // METHOD \n PATH \n TIMESTAMP \n CUSTOMER_ID \n BODY
16
+ // keyed by the user's secretKey (raw UTF-8), HMAC-SHA256, lowercase hex.
17
+ //
18
+ // `bodyString` MUST be the exact bytes sent on the wire. For a body-less GET, leave it
19
+ // empty ('') — the trailing '\n' + empty segment matches the server's `body: b""`.
20
+ // `timestamp` is injectable for deterministic tests; it must be reused verbatim in both
21
+ // the signed canonical and the `timestamp` header.
22
+ //
23
+ // Content-Type is intentionally NOT returned here (it is not part of the signature); the
24
+ // caller adds it when sending a request body.
25
+ const buildSignedHeaders = ({ method, path, customerId, secretKey, bodyString = '', timestamp = Math.floor(Date.now() / 1000).toString(), }) => {
26
+ const canonical = [method, path, timestamp, customerId, bodyString].join('\n');
27
+ const signature = (0, crypto_1.createHmac)('sha256', secretKey)
28
+ .update(canonical, 'utf8')
29
+ .digest('hex');
30
+ return {
31
+ 'customer-id': customerId,
32
+ timestamp,
33
+ signature,
34
+ };
35
+ };
36
+ exports.buildSignedHeaders = buildSignedHeaders;
37
+ // Customer ID is the registered phone number; tolerate a pasted "+" or spaces.
38
+ // Normalize ONCE and reuse the same value for both the signed canonical and the
39
+ // `customer-id` header, so they cannot disagree.
40
+ const normalizeCustomerId = (customerId) => customerId.replace(/[\s+]/g, '');
41
+ exports.normalizeCustomerId = normalizeCustomerId;
@@ -0,0 +1,16 @@
1
+ declare const MAX_SKEW_SECONDS = 300;
2
+ export type VerifyResult = {
3
+ ok: true;
4
+ } | {
5
+ ok: false;
6
+ reason: 'missing' | 'unverifiable' | 'invalid' | 'skew';
7
+ };
8
+ export interface VerifySignatureParams {
9
+ signingSecret: string;
10
+ rawBody: unknown;
11
+ signatureHeader: unknown;
12
+ timestampHeader: unknown;
13
+ now?: number;
14
+ }
15
+ export declare const verifySignature: ({ signingSecret, rawBody, signatureHeader, timestampHeader, now, }: VerifySignatureParams) => VerifyResult;
16
+ export { MAX_SKEW_SECONDS };
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_SKEW_SECONDS = exports.verifySignature = void 0;
4
+ const crypto_1 = require("crypto");
5
+ // Max allowed clock skew (seconds) between the delivery's signed timestamp and our own
6
+ // clock. Matches the server's replay window documented in the callback spec (±300s).
7
+ const MAX_SKEW_SECONDS = 300;
8
+ exports.MAX_SKEW_SECONDS = MAX_SKEW_SECONDS;
9
+ // Pure, n8n-free verifier for an inbound event delivery — mirrors how `signing.ts` keeps
10
+ // the crypto separate from the node plumbing so it can be unit-tested in isolation.
11
+ //
12
+ // Per the `inboundMessageReceived` callback contract, each delivery is signed as:
13
+ // expected = HMAC_SHA256(signingSecret, `${timestamp}.${rawBody}`) (lowercase hex)
14
+ // and delivered in the `X-VoxiSMS-Signature` header with a `sha256=` prefix, alongside the
15
+ // signed `X-VoxiSMS-Timestamp` (Unix seconds). `rawBody` MUST be the exact raw request
16
+ // bytes — never a re-serialized parse — or key reordering/whitespace breaks the digest.
17
+ //
18
+ // Returns a plain result object rather than throwing, so the caller decides how each
19
+ // outcome maps onto the node's throw-vs-drop policy:
20
+ // { ok: true } — signature valid and timestamp fresh
21
+ // { ok: false, reason: 'missing' } — no (usable) signature header / no timestamp
22
+ // (unsigned noise → caller drops)
23
+ // { ok: false, reason: 'unverifiable' } — signature present but there are no raw bytes
24
+ // to check it against (→ caller surfaces)
25
+ // { ok: false, reason: 'invalid' } — signature present but does not match
26
+ // (secret/config drift → caller surfaces)
27
+ // { ok: false, reason: 'skew' } — signature valid but timestamp out of window
28
+ // (replay / clock drift → caller surfaces)
29
+ const verifySignature = ({ signingSecret, rawBody, signatureHeader, timestampHeader, now = Math.floor(Date.now() / 1000), }) => {
30
+ // A non-string header (e.g. a multi-value array) is unusable — treat as unsigned noise,
31
+ // same as a fully absent header. Guarding here keeps the `.startsWith`/length ops below
32
+ // from throwing on a non-string.
33
+ if (typeof signatureHeader !== 'string' || !signatureHeader || !timestampHeader) {
34
+ return { ok: false, reason: 'missing' };
35
+ }
36
+ // Signature IS present but we have no raw bytes to verify it against. We can't clear it,
37
+ // and a present-but-unverifiable signature must not be silently dropped — surface it.
38
+ if (typeof rawBody !== 'string') {
39
+ return { ok: false, reason: 'unverifiable' };
40
+ }
41
+ // `timestampHeader` is truthy here, but may still be a non-string (e.g. array); coerce to
42
+ // a string so it is safe to interpolate into the canonical and to `Number()` below.
43
+ const timestampString = String(timestampHeader);
44
+ // Strip the `sha256=` prefix the header carries (tolerate its absence defensively) and
45
+ // lowercase it — the expected digest is lowercase hex, so an uppercase-but-correct digest
46
+ // should still compare equal.
47
+ const provided = (signatureHeader.startsWith('sha256=')
48
+ ? signatureHeader.slice('sha256='.length)
49
+ : signatureHeader).toLowerCase();
50
+ const expected = (0, crypto_1.createHmac)('sha256', signingSecret)
51
+ .update(`${timestampString}.${rawBody}`, 'utf8')
52
+ .digest('hex');
53
+ // Constant-time compare. timingSafeEqual throws on length mismatch, so guard lengths
54
+ // first — a wrong length is, itself, a non-match (present but invalid).
55
+ const providedBuf = Buffer.from(provided, 'utf8');
56
+ const expectedBuf = Buffer.from(expected, 'utf8');
57
+ if (providedBuf.length !== expectedBuf.length ||
58
+ !(0, crypto_1.timingSafeEqual)(providedBuf, expectedBuf)) {
59
+ return { ok: false, reason: 'invalid' };
60
+ }
61
+ // Signature is authentic; enforce the replay window on the (now trusted) timestamp.
62
+ const ts = Number(timestampString);
63
+ if (!Number.isFinite(ts) || Math.abs(now - ts) > MAX_SKEW_SECONDS) {
64
+ return { ok: false, reason: 'skew' };
65
+ }
66
+ return { ok: true };
67
+ };
68
+ exports.verifySignature = verifySignature;
package/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // Intentionally empty. n8n loads the nodes and credentials from the paths declared in
2
+ // the package.json `n8n` block (dist/...), not from this entry point — it exists only so
3
+ // the package's `main` field resolves.
4
+ module.exports = {};
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@voxiness/n8n-nodes-voxisms",
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "n8n community nodes for VoxiSMS: send SMS and trigger on inbound SMS",
8
+ "keywords": [
9
+ "n8n-community-node-package",
10
+ "sms",
11
+ "voxisms"
12
+ ],
13
+ "license": "MIT",
14
+ "homepage": "https://voxisms.com",
15
+ "author": {
16
+ "name": "VoxiSMS"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/aboven/voxisms-automations.git",
21
+ "directory": "voxisms-n8n"
22
+ },
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "main": "index.js",
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "scripts": {
31
+ "build": "tsc && node scripts/copy-icons.mjs",
32
+ "dev": "tsc --watch",
33
+ "lint": "tsc --noEmit",
34
+ "prepublishOnly": "npm run build && npm test",
35
+ "test": "jest"
36
+ },
37
+ "n8n": {
38
+ "n8nNodesApiVersion": 1,
39
+ "credentials": [
40
+ "dist/credentials/VoxiSmsApi.credentials.js"
41
+ ],
42
+ "nodes": [
43
+ "dist/nodes/VoxiSms/VoxiSms.node.js",
44
+ "dist/nodes/VoxiSmsTrigger/VoxiSmsTrigger.node.js"
45
+ ]
46
+ },
47
+ "devDependencies": {
48
+ "@types/jest": "^29.5.0",
49
+ "@types/node": "^20.0.0",
50
+ "jest": "^29.6.0",
51
+ "n8n-workflow": "*",
52
+ "ts-jest": "^29.1.0",
53
+ "typescript": "^5.4.0"
54
+ },
55
+ "peerDependencies": {
56
+ "n8n-workflow": "*"
57
+ }
58
+ }