@dataflows/sms 0.0.0-stage → 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 DataFlows Australia Pty Ltd
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 CHANGED
@@ -1,3 +1,123 @@
1
- # Temporary Holding Version
1
+ # DataFlows SMS for Node.js
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The official Node.js client for the [DataFlows SMS API](https://dataflows.com.au/developers). It has no dependencies and needs Node 18 or newer.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @dataflows/sms
9
+ ```
10
+
11
+ ## Send your first SMS
12
+
13
+ Find your API token in your DataFlows account under Developers, and keep it in an environment variable:
14
+
15
+ ```bash
16
+ export DATAFLOWS_API_TOKEN="your-token"
17
+ ```
18
+
19
+ ```js
20
+ import { DataFlows } from '@dataflows/sms';
21
+
22
+ const client = new DataFlows();
23
+
24
+ const result = await client.sms.send({
25
+ recipient: '61404235133',
26
+ senderId: 'YOUR_SENDER_ID',
27
+ message: 'Hello from DataFlows',
28
+ });
29
+
30
+ console.log(result.campaign_id);
31
+ ```
32
+
33
+ This really sends a text and uses your credit, so change the number first. `require` works too:
34
+
35
+ ```js
36
+ const { DataFlows } = require('@dataflows/sms');
37
+ ```
38
+
39
+ ## What you can do
40
+
41
+ | Call | What it does |
42
+ |---|---|
43
+ | `client.account.profile()` | Your account details |
44
+ | `client.account.balance()` | Your remaining balance |
45
+ | `client.senderIds.list()` | Your active sender IDs |
46
+ | `client.sms.send({ recipient, senderId, message, scheduleTime })` | Send or schedule an SMS |
47
+ | `client.sms.list({ page, limit })` | A page of your sent messages |
48
+ | `client.sms.get(uid)` | One message |
49
+ | `client.groups.list({ page })` | A page of your contact groups |
50
+ | `client.groups.create({ name })` | Add a group |
51
+ | `client.groups.get(groupUid)` | One group |
52
+ | `client.groups.update(groupUid, { name })` | Rename a group |
53
+ | `client.groups.delete(groupUid)` | Delete a group |
54
+ | `client.contacts.list(groupUid, { page })` | A page of a group's contacts |
55
+ | `client.contacts.create(groupUid, { phone, firstName, lastName, fields })` | Add a contact |
56
+ | `client.contacts.get(groupUid, contactUid)` | One contact |
57
+ | `client.contacts.update(groupUid, contactUid, { phone, firstName, lastName, fields })` | Change a contact |
58
+ | `client.contacts.delete(groupUid, contactUid)` | Delete a contact |
59
+
60
+ ## Sending
61
+
62
+ **Numbers** go in international format, such as `61404235133`. A number containing letters is refused before anything is sent.
63
+
64
+ **Several numbers:** pass a list as `recipient`. They're sent during the request, so a long list takes longer, and you get one result per number.
65
+
66
+ **Scheduling:** set `scheduleTime` to `YYYY-MM-DD HH:mm` in your account's timezone.
67
+
68
+ **Delivery status:** a message's `customer_status` of `Delivered` means the carrier accepted it. It doesn't confirm the message reached the phone.
69
+
70
+ **Trial accounts** can only send to numbers they have verified.
71
+
72
+ ## Contacts
73
+
74
+ Adding a contact sends the group's welcome text if you've set one up on that group. `fields` holds your group's custom fields, by tag. Leave `phone` out of an update to keep the current number.
75
+
76
+ ## Errors
77
+
78
+ Every failure throws a `DataFlowsError`. This includes errors the API answers with HTTP 200, which many of them do.
79
+
80
+ ```js
81
+ import { DataFlows, DataFlowsError } from '@dataflows/sms';
82
+
83
+ try {
84
+ await client.sms.send({ recipient: '61404235133', senderId: 'YOUR_SENDER_ID', message: 'Hello' });
85
+ } catch (error) {
86
+ if (error instanceof DataFlowsError) {
87
+ console.error(error.code, error.httpStatus, error.message);
88
+ } else {
89
+ throw error;
90
+ }
91
+ }
92
+ ```
93
+
94
+ | `error.code` | Meaning |
95
+ |---|---|
96
+ | `api_error` | DataFlows refused the request. `error.message` says why |
97
+ | `invalid_input` | Something is missing or wrong. Nothing was sent to DataFlows |
98
+ | `rate_limited` | Over 600 requests per minute for this token |
99
+ | `timeout` | No answer in time. A send may still have gone out |
100
+ | `network_error` | DataFlows couldn't be reached |
101
+ | `invalid_response` | DataFlows sent back something unexpected |
102
+
103
+ **Nothing is retried automatically**, so a message can't go out twice because of this client. After a `timeout`, check `client.sms.list()` before you send again.
104
+
105
+ ## Options
106
+
107
+ ```js
108
+ const client = new DataFlows({
109
+ apiToken: 'your-token', // defaults to DATAFLOWS_API_TOKEN
110
+ timeout: 60000, // milliseconds per request, this is the default
111
+ });
112
+ ```
113
+
114
+ ## More
115
+
116
+ - [Developer hub](https://dataflows.com.au/developers)
117
+ - [API reference](https://docs.dataflows.com.au/)
118
+ - [OpenAPI spec](https://sms.dataflows.com.au/openapi.json)
119
+ - Help: support@dataflows.com.au
120
+
121
+ ## License
122
+
123
+ MIT
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@dataflows/sms",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Official Node.js client for the DataFlows SMS API",
5
+ "keywords": ["sms", "dataflows", "text message", "australia", "api"],
6
+ "homepage": "https://dataflows.com.au/developers",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/dataflows-au/dataflows-sms-node.git"
10
+ },
11
+ "bugs": {
12
+ "email": "support@dataflows.com.au"
13
+ },
14
+ "license": "MIT",
15
+ "author": "DataFlows Australia Pty Ltd",
16
+ "main": "./src/index.cjs",
17
+ "types": "./src/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./src/index.d.ts",
21
+ "import": "./src/index.mjs",
22
+ "require": "./src/index.cjs"
23
+ }
24
+ },
25
+ "files": ["src"],
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "test": "node --test test/client.test.cjs"
34
+ }
35
+ }
package/src/index.cjs ADDED
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+
3
+ const VERSION = '0.1.0';
4
+ const DEFAULT_BASE_URL = 'https://sms.dataflows.com.au/api/http';
5
+ const DEFAULT_TIMEOUT_MS = 60000;
6
+
7
+ // Digits and the usual separators only. The API's number parser reads letters
8
+ // as a phone keypad, so "61412345xxx" would otherwise reach a real number.
9
+ const VALID_NUMBER = /^(?=.*[0-9])[0-9+()\-\s]+$/;
10
+
11
+ class DataFlowsError extends Error {
12
+ /**
13
+ * @param {string} message
14
+ * @param {{code?: string, httpStatus?: number, response?: unknown, cause?: unknown}} [details]
15
+ */
16
+ constructor(message, { code = 'api_error', httpStatus, response, cause } = {}) {
17
+ super(message, cause === undefined ? undefined : { cause });
18
+ this.name = 'DataFlowsError';
19
+ this.code = code;
20
+ this.httpStatus = httpStatus;
21
+ this.response = response;
22
+ }
23
+ }
24
+
25
+ function requireValue(value, name) {
26
+ if (value === undefined || value === null || value === '') {
27
+ throw new DataFlowsError(`${name} is required.`, { code: 'invalid_input' });
28
+ }
29
+ }
30
+
31
+ function recipientValue(recipient) {
32
+ requireValue(recipient, 'recipient');
33
+ const numbers = (Array.isArray(recipient) ? recipient : String(recipient).split(','))
34
+ .map((number) => String(number).trim());
35
+
36
+ if (numbers.length === 0) {
37
+ throw new DataFlowsError('recipient is required.', { code: 'invalid_input' });
38
+ }
39
+
40
+ const invalid = numbers.find((number) => !VALID_NUMBER.test(number));
41
+ if (invalid !== undefined) {
42
+ throw new DataFlowsError(`"${invalid}" is not a valid phone number. Use digits only.`, { code: 'invalid_input' });
43
+ }
44
+ return numbers.join(',');
45
+ }
46
+
47
+ function contactBody({ phone, firstName, lastName, fields } = {}) {
48
+ const body = { ...fields };
49
+ if (phone !== undefined) body.PHONE = phone;
50
+ if (firstName !== undefined) body.FIRST_NAME = firstName;
51
+ if (lastName !== undefined) body.LAST_NAME = lastName;
52
+ return body;
53
+ }
54
+
55
+ function segment(value, name) {
56
+ requireValue(value, name);
57
+ return encodeURIComponent(String(value));
58
+ }
59
+
60
+ class DataFlows {
61
+ /**
62
+ * @param {{apiToken?: string, baseUrl?: string, timeout?: number, fetch?: typeof fetch}} [options]
63
+ */
64
+ constructor({ apiToken = process.env.DATAFLOWS_API_TOKEN, baseUrl = DEFAULT_BASE_URL, timeout = DEFAULT_TIMEOUT_MS, fetch: fetchImpl = globalThis.fetch } = {}) {
65
+ if (!apiToken) {
66
+ throw new DataFlowsError('An API token is required. Pass apiToken or set DATAFLOWS_API_TOKEN.', { code: 'invalid_input' });
67
+ }
68
+ if (!/^https:\/\//.test(baseUrl) && !/^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/.test(baseUrl)) {
69
+ throw new DataFlowsError('baseUrl must use https, so your API token is never sent unencrypted.', { code: 'invalid_input' });
70
+ }
71
+ if (typeof fetchImpl !== 'function') {
72
+ throw new DataFlowsError('No fetch is available. Use Node 18 or newer, or pass your own fetch.', { code: 'invalid_input' });
73
+ }
74
+
75
+ const root = baseUrl.replace(/\/+$/, '');
76
+
77
+ // Held in this closure, not on the instance, so logging the client can't show the token.
78
+ const call = async (method, path, { query, body } = {}) => {
79
+ const search = new URLSearchParams();
80
+ for (const [key, value] of Object.entries(query ?? {})) {
81
+ if (value !== undefined && value !== null) search.set(key, String(value));
82
+ }
83
+ const queryString = search.toString();
84
+ const url = root + path + (queryString ? `?${queryString}` : '');
85
+
86
+ const headers = {
87
+ Accept: 'application/json',
88
+ Authorization: `Bearer ${apiToken}`,
89
+ 'User-Agent': `dataflows-sms-node/${VERSION}`,
90
+ };
91
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
92
+
93
+ let response;
94
+ try {
95
+ response = await fetchImpl(url, {
96
+ method,
97
+ headers,
98
+ body: body === undefined ? undefined : JSON.stringify(body),
99
+ signal: AbortSignal.timeout(timeout),
100
+ });
101
+ } catch (cause) {
102
+ if (cause?.name === 'TimeoutError' || cause?.name === 'AbortError') {
103
+ throw new DataFlowsError(`The request timed out after ${timeout}ms. If this was a send, the message may still have gone out, so check before trying again.`, { code: 'timeout', cause });
104
+ }
105
+ throw new DataFlowsError(`Could not reach DataFlows: ${cause?.message ?? cause}`, { code: 'network_error', cause });
106
+ }
107
+
108
+ let payload;
109
+ try {
110
+ payload = await response.json();
111
+ } catch (cause) {
112
+ throw new DataFlowsError(`DataFlows sent a response that isn't JSON (HTTP ${response.status}).`, { code: 'invalid_response', httpStatus: response.status, cause });
113
+ }
114
+
115
+ // Many errors answer HTTP 200 with status "error", so the HTTP code alone isn't enough.
116
+ if (!response.ok || payload?.status === 'error') {
117
+ throw new DataFlowsError(payload?.message || `DataFlows answered HTTP ${response.status}.`, {
118
+ code: response.status === 429 ? 'rate_limited' : 'api_error',
119
+ httpStatus: response.status,
120
+ response: payload,
121
+ });
122
+ }
123
+ return payload;
124
+ };
125
+
126
+ const data = async (...args) => (await call(...args)).data;
127
+ const done = async (...args) => { await call(...args); };
128
+
129
+ this.account = {
130
+ profile: () => data('GET', '/me'),
131
+ balance: () => data('GET', '/balance'),
132
+ };
133
+
134
+ this.senderIds = {
135
+ list: async () => (await call('GET', '/sender-ids')).value,
136
+ };
137
+
138
+ this.sms = {
139
+ send: async ({ recipient, senderId, message, scheduleTime } = {}) => {
140
+ const to = recipientValue(recipient);
141
+ requireValue(senderId, 'senderId');
142
+ requireValue(message, 'message');
143
+ const body = { recipient: to, sender_id: senderId, message };
144
+ if (scheduleTime !== undefined) body.schedule_time = scheduleTime;
145
+ return data('POST', '/sms/send', { body });
146
+ },
147
+ list: ({ page, limit } = {}) => data('GET', '/sms', { query: { page, limit } }),
148
+ get: async (uid) => data('GET', `/sms/${segment(uid, 'uid')}`),
149
+ };
150
+
151
+ this.groups = {
152
+ list: ({ page } = {}) => data('GET', '/contacts', { query: { page } }),
153
+ create: async ({ name } = {}) => {
154
+ requireValue(name, 'name');
155
+ return data('POST', '/contacts', { body: { name } });
156
+ },
157
+ get: async (groupUid) => data('POST', `/contacts/${segment(groupUid, 'groupUid')}/show`),
158
+ update: async (groupUid, { name } = {}) => {
159
+ requireValue(name, 'name');
160
+ return data('PATCH', `/contacts/${segment(groupUid, 'groupUid')}`, { body: { name } });
161
+ },
162
+ delete: async (groupUid) => done('DELETE', `/contacts/${segment(groupUid, 'groupUid')}`),
163
+ };
164
+
165
+ this.contacts = {
166
+ list: async (groupUid, { page } = {}) => data('POST', `/contacts/${segment(groupUid, 'groupUid')}/all`, { query: { page } }),
167
+ create: async (groupUid, input = {}) => {
168
+ requireValue(input.phone, 'phone');
169
+ return data('POST', `/contacts/${segment(groupUid, 'groupUid')}/store`, { body: contactBody(input) });
170
+ },
171
+ get: async (groupUid, contactUid) => data('POST', `/contacts/${segment(groupUid, 'groupUid')}/search/${segment(contactUid, 'contactUid')}`),
172
+ update: async (groupUid, contactUid, input = {}) => data('PATCH', `/contacts/${segment(groupUid, 'groupUid')}/update/${segment(contactUid, 'contactUid')}`, { body: contactBody(input) }),
173
+ delete: async (groupUid, contactUid) => done('DELETE', `/contacts/${segment(groupUid, 'groupUid')}/delete/${segment(contactUid, 'contactUid')}`),
174
+ };
175
+ }
176
+ }
177
+
178
+ module.exports = { DataFlows, DataFlowsError, VERSION };
package/src/index.d.ts ADDED
@@ -0,0 +1,152 @@
1
+ export declare const VERSION: string;
2
+
3
+ export type DataFlowsErrorCode =
4
+ | 'api_error'
5
+ | 'invalid_input'
6
+ | 'invalid_response'
7
+ | 'network_error'
8
+ | 'rate_limited'
9
+ | 'timeout';
10
+
11
+ export declare class DataFlowsError extends Error {
12
+ readonly code: DataFlowsErrorCode;
13
+ /** The HTTP status, when DataFlows answered. Many API errors answer 200. */
14
+ readonly httpStatus?: number;
15
+ /** The parsed error response, when there was one. */
16
+ readonly response?: unknown;
17
+ }
18
+
19
+ export interface DataFlowsOptions {
20
+ /** Your API token. Defaults to the DATAFLOWS_API_TOKEN environment variable. */
21
+ apiToken?: string;
22
+ baseUrl?: string;
23
+ /** Milliseconds to wait for each request. Defaults to 60000. */
24
+ timeout?: number;
25
+ fetch?: typeof fetch;
26
+ }
27
+
28
+ export interface Profile {
29
+ uid: string;
30
+ api_token?: string;
31
+ first_name?: string | null;
32
+ last_name?: string | null;
33
+ email: string;
34
+ locale?: string;
35
+ timezone?: string;
36
+ last_access_at?: string | null;
37
+ }
38
+
39
+ export interface Balance {
40
+ remaining_balance: string;
41
+ expired_on: string;
42
+ }
43
+
44
+ export interface SendResult {
45
+ campaign_id: string;
46
+ recipient?: string;
47
+ message?: string;
48
+ cost?: number;
49
+ sms_count?: number;
50
+ status?: string;
51
+ }
52
+
53
+ export interface Message {
54
+ uid: string;
55
+ to: string;
56
+ from?: string | null;
57
+ message?: string;
58
+ /** "Delivered" means the carrier accepted the message, not that it reached the phone. */
59
+ customer_status?: string | null;
60
+ cost?: string | null;
61
+ }
62
+
63
+ export interface Group {
64
+ uid: string;
65
+ name: string;
66
+ }
67
+
68
+ export interface Contact {
69
+ uid: string;
70
+ phone: number;
71
+ status: string;
72
+ custom_fields?: Record<string, unknown>;
73
+ }
74
+
75
+ export interface Page<T> {
76
+ current_page: number;
77
+ data: T[];
78
+ first_page_url?: string;
79
+ from?: number | null;
80
+ last_page: number;
81
+ last_page_url?: string;
82
+ links?: Array<{ url: string | null; label: string; active: boolean }>;
83
+ next_page_url?: string | null;
84
+ path?: string;
85
+ per_page: number;
86
+ prev_page_url?: string | null;
87
+ to?: number | null;
88
+ total: number;
89
+ }
90
+
91
+ export interface SendSmsInput {
92
+ /** One number in international format. Sending to several is a separate overload. */
93
+ recipient: string;
94
+ /** One of your active sender IDs or numbers. See senderIds.list(). */
95
+ senderId: string;
96
+ message: string;
97
+ /** Send later, as "YYYY-MM-DD HH:mm" in your account's timezone. */
98
+ scheduleTime?: string;
99
+ }
100
+
101
+ export interface SendBulkSmsInput extends Omit<SendSmsInput, 'recipient'> {
102
+ /** Several numbers. They're sent during the request, so a long list takes longer. */
103
+ recipient: string[];
104
+ }
105
+
106
+ export interface ContactInput {
107
+ phone?: string;
108
+ firstName?: string;
109
+ lastName?: string;
110
+ /** Your group's custom fields, by tag. */
111
+ fields?: Record<string, string>;
112
+ }
113
+
114
+ export declare class DataFlows {
115
+ constructor(options?: DataFlowsOptions);
116
+
117
+ readonly account: {
118
+ profile(): Promise<Profile>;
119
+ balance(): Promise<Balance>;
120
+ };
121
+
122
+ readonly senderIds: {
123
+ list(): Promise<string[]>;
124
+ };
125
+
126
+ readonly sms: {
127
+ send(input: SendSmsInput): Promise<SendResult>;
128
+ send(input: SendBulkSmsInput): Promise<Message[]>;
129
+ list(options?: { page?: number; limit?: number }): Promise<Page<Message>>;
130
+ get(uid: string): Promise<Message>;
131
+ };
132
+
133
+ readonly groups: {
134
+ list(options?: { page?: number }): Promise<Page<Group>>;
135
+ create(input: { name: string }): Promise<Group>;
136
+ get(groupUid: string): Promise<Group>;
137
+ update(groupUid: string, input: { name: string }): Promise<Group>;
138
+ delete(groupUid: string): Promise<void>;
139
+ };
140
+
141
+ readonly contacts: {
142
+ list(groupUid: string, options?: { page?: number }): Promise<Page<Contact>>;
143
+ /** Sends the group's welcome text if you've set one up on that group. */
144
+ create(groupUid: string, input: ContactInput & { phone: string }): Promise<Contact>;
145
+ get(groupUid: string, contactUid: string): Promise<Contact>;
146
+ /** Leave phone out to keep the current number. */
147
+ update(groupUid: string, contactUid: string, input: ContactInput): Promise<Contact>;
148
+ delete(groupUid: string, contactUid: string): Promise<void>;
149
+ };
150
+ }
151
+
152
+ export default DataFlows;
package/src/index.mjs ADDED
@@ -0,0 +1,4 @@
1
+ import sdk from './index.cjs';
2
+
3
+ export const { DataFlows, DataFlowsError, VERSION } = sdk;
4
+ export default DataFlows;