TypeScript client for the Sendly SMS API (https://api.sendly.link).
Works on Node.js 18+ and in any modern runtime with a built-in fetch.
npm install sendly-clientThe API token is generated in the Sendly customer panel (or provided by
customer support). The conventional environment variables are SENDLY_TOKEN,
SENDLY_BASE_URL (optional URL override) and SENDLY_FROM (optional default
sender); the older names ACTIO_TOKEN, ACTIO_BASE_URL and ACTIO_FROM
remain supported as a fallback (when both are set, SENDLY_* takes
precedence). SendlyClient reads SENDLY_TOKEN/ACTIO_TOKEN and
SENDLY_BASE_URL/ACTIO_BASE_URL itself when not passed explicitly to the
constructor; SENDLY_FROM/ACTIO_FROM is not read by this client and must
be passed as from on each call.
import { SendlyClient } from 'sendly-client';
const client = new SendlyClient({
token: process.env.SENDLY_TOKEN!,
// baseUrl: process.env.SENDLY_BASE_URL, // optional, default https://api.sendly.link
// timeoutMs: 30000, // optional, default 30 s
});
const { messageId } = await client.sendSms({
from: '48732129000',
to: '48732129001',
body: 'Test Sendly',
});
console.log(messageId); // unique message id, e.g. "a906cff7719bd889"The message_id field (snake_case in the API) is exposed throughout the
client as messageId (single send, sendSmsMulti result entries, and
NOTIFICATION webhook events).
Equivalent curl request:
curl -X POST https://api.sendly.link/api/sms \
-H "Authorization: Bearer $SENDLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"from": "48732129000", "to": "48732129001", "body": "Test Sendly"}'sendSmsMulti accepts 1–100 unique destination numbers. Server-side
validation is all-or-nothing: every number must be a valid Polish mobile
number and the numbers must be unique – otherwise the whole request is
rejected. This API method must additionally be activated for your token by
Sendly customer support.
const results = await client.sendSmsMulti({
from: '48732129000',
to: ['48732129001', '48732129002'],
body: 'Test Sendly',
});
// [{ number: '48732129001', messageId: '...' }, { number: '48732129002', messageId: '...' }]Equivalent curl request:
curl -X POST https://api.sendly.link/api/sms-multi \
-H "Authorization: Bearer $SENDLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"from": "48732129000", "to": ["48732129001", "48732129002"], "body": "Test Sendly"}'Sendly POSTs incoming messages and (once activated) delivery notifications to
the webhook URL configured in the customer panel. A single delivery attempt
is made, redirects are not followed, and no authentication is performed
against the webhook; the request originates from the current IP address of
api.sendly.link.
parseWebhook turns a parsed JSON payload into a typed event discriminated
on the type field (IncomingMessage | DeliveryNotification):
import { createServer } from 'node:http';
import { parseWebhook } from 'sendly-client';
createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('error', (err) => {
console.error('Webhook request error:', err);
});
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
try {
const event = parseWebhook(JSON.parse(raw));
if (event.type === 'MESSAGE') {
console.log(`SMS from ${event.from} to ${event.to}: ${event.body}`);
} else {
console.log(`Message ${event.messageId} status: ${event.status}`); // DELIVERED | ERROR
}
res.writeHead(200).end();
} catch (err) {
console.error('Invalid webhook payload:', err);
res.writeHead(400).end();
}
});
}).listen(3000);Example payloads:
{"type": "MESSAGE", "from": "48732129000", "to": "48732129001", "body": "Test sms"}
{"type": "NOTIFICATION", "message_id": "a906cff7719bd889", "status": "DELIVERED"}parseWebhook throws SendlyValidationError for payloads that don't match
any known shape.
All errors thrown by this library inherit from SendlyError:
SendlyValidationError– client-side validation failed before any HTTP request was made (from/tomust be numeric strings of 9–11 digits,bodynon-empty, multi-sendtomust contain 1–100 unique numbers).SendlyApiError– the API responded with a non-2xx status (403authorization problem,422validation problem). CarriesstatusCode, a parsederrorsmap (optional keystoken,from,to,body, each an array of strings), andrawBodywith the verbatim response body as a fallback for non-JSON or unexpectedly shaped responses.
import { SendlyApiError, SendlyValidationError } from 'sendly-client';
try {
await client.sendSms({ from: '48732129000', to: '48732129001', body: 'Hi' });
} catch (error) {
if (error instanceof SendlyValidationError) {
console.error('Invalid input:', error.message);
} else if (error instanceof SendlyApiError) {
console.error(`HTTP ${error.statusCode}`, error.errors, error.rawBody);
} else {
throw error; // network failure, timeout (TimeoutError), ...
}
}Every outgoing request to the API carries, alongside Authorization and
Content-Type:
User-Agent: sendly-javascript-client/<version> (Node/<major>), e.g.sendly-javascript-client/2.0.0 (Node/22)– the version comes frompackage.json(generated intosrc/version.tsat build time);X-Request-Id: <uuid>– a fresh UUID v4 for every request (Web Crypto'srandomUUID()where available), a correlation id you can hand to Sendly support, e.g. when investigating a suspected double send.
The client never retries failed requests – a retry could send an SMS twice. If you need retries, implement them yourself with idempotency in mind.
Requests are aborted after timeoutMs (default 30,000 ms) using
AbortSignal.timeout; a request aborted by the timeout rejects with a
TimeoutError (DOMException, not a SendlyError).
Outgoing messages are encoded as UCS2 and split every 60 characters. Optionally, GSM7 encoding can be enabled (customer panel / customer support) with a limit of 160 characters for a single message.
The 3CX SMS API mode (/api/tcx) is mutually exclusive with the REST SMS API
and is not implemented by this client.
npm install
npm run build # compiles src/ to dist/ (ESM + type declarations)
npm test # builds, then runs node --test against the compiled outputTests never call the real API – the HTTP layer is replaced with an injected
fake fetch.
MIT – Copyright (c) 2026 ACTIO. See LICENSE.