TypeScript Library v3.00

bestsms

The bestsms package, listed on npm, is BestSMS Australia's own TypeScript client for the BestSMS REST API v3.00. With it you can send text messages and look after your Addressbook and OptOut list from Node.js, with typed request arguments and typed results, and no need to write HTTP plumbing or response parsing. It works equally well from plain JavaScript.

Quick Example

import { BestSMS, ErrorResponseDTO } from 'bestsms';

const client = new BestSMS({ AuthToken: "[Your Auth Token]" });

const result = await client.Messaging.SMS.SendMessage({
    Message: "Hello from BestSMS",
    Destination: "+61491570006",
    Mode: "Test"
});

if (result instanceof ErrorResponseDTO) {
    console.error(result.Result, result.ErrorMessage);
} else {
    console.log(`Accepted - MessageID: ${result.MessageID}`);
}

Mode: "Test" makes the API treat the call as a successful submission without sending or charging anything. Take it out when you are ready to send for real. The Getting Started section explains where an Auth Token comes from.

What you can do

Messaging:

  • SMS: send messages, then follow up on replies and inbound texts

Account tools:

  • Addressbook: contacts, groups and which contacts belong to which group
  • OptOut: keep track of destinations that must not be messaged
  • Webhooks: typed payloads and a parseWebhook() helper for events that BestSMS pushes to your own server
  • Reports: poll for message status, SMS replies and received SMS
  • Actions: abort an SMS, or move it to a new send time, while it is still delayed

Supported Node.js versions

This is a server-side library. Requests go out through Node's built-in http and https modules, and SMS attachments are read from disk with fs, so it cannot be bundled for a browser. Code running in the browser (client-side React, Vue or Angular, or a plain <script> tag) should call your own backend, which in turn uses bestsms.

  • Node.js: package.json sets no engines constraint. The code is compiled to an ES2020 target, which leaves optional chaining (?.) as written, so Node.js 14 is the practical minimum. The project's own build pipeline runs on Node.js 22.
  • Module format: CommonJS, with .d.ts declarations included. Importing from an ES module project ("type": "module") also works: import { BestSMS } from 'bestsms' is valid in both module systems.
  • TypeScript: the declarations use the export type * syntax, which arrived in TypeScript 5.0, so use 5.0 or later. The library itself is built with TypeScript 5.4 and above (^5.4.5). If you work in plain JavaScript, the types are simply ignored.

Only one runtime dependency is pulled in, google-libphonenumber, which the SDK uses to check that SMS destination numbers are mobile numbers before a request is made.

Installation

Add the package to your project with npm:

npm i bestsms

The source repository includes samples/ with runnable scripts for Messaging, Addressbook, OptOut, Actions, Reports and Webhooks. It also holds demo/, a small local-only NestJS REST API that wraps one endpoint around each SDK call.

Upgrading from bestsms 2.x

Release 3.x is an API v3.00 client and is not a drop-in replacement for the 2.x package. Documentation for the older client stays available under the v2.04 Node.js reference. The main differences are:

  • The client is now a named export: import { BestSMS } from 'bestsms' replaces require('bestsms') with a default export.
  • The constructor takes AuthToken and an optional URL. The Sender and APIKey arguments are gone.
  • Calls follow the v3.00 resource paths (/sms, /addressbook, /optout) instead of the old Send, Get and Set groupings, and every method returns a Promise of a typed result or an ErrorResponseDTO.
  • Actions.Resubmit has been removed. Abort and Reschedule remain, apply to SMS only, and take no Channel argument.
  • New in 3.x: Contact.Search, the OptOut module (including Update and Batch), webhook types, Mode: "Test", TemplateID and attachments.

When you are ready, go to Getting Started and create your first BestSMS client.

Getting Started

Every integration starts by creating a BestSMS client, the object that gives you access to the whole BestSMS API.

Register an Account

You need a BestSMS account before anything else. No account yet? Register here, then return to this page.

API Credentials

Each request that bestsms makes carries your Auth Token in an Authorization: Bearer header. The token is never placed in a request body.

Export your Auth Token

  1. Sign in to the BestSMS Dashboard
  2. Open the 'Users' page
  3. Add a new user, or pick an existing one
  4. Switch on API access if it is currently off
  5. Open its 'API' tab
  6. Turn on 'Auth Token', then generate a fresh token
  7. Press 'Copy' to put the token on your clipboard

Hand the token to the BestSMS constructor:

import { BestSMS } from 'bestsms';

const client = new BestSMS({ AuthToken: "[Your Auth Token]" });

The constructor takes an optional object with two fields, AuthToken and URL (the API base URL). It creates all five facades straight away: .Messaging, .Reports, .Actions, .Addressbook and .OptOut. There is just this one way to build a client, and no separate user object to set up first.

The Auth Token is mandatory

You must supply a token through either the AuthToken argument or the BESTSMS_AUTH_TOKEN environment variable. When both are missing, new BestSMS() throws straight away:

Error: BestSMS AuthToken is required. Pass it as AuthToken or set the BESTSMS_AUTH_TOKEN environment variable.

That constructor check is the only place where the client throws for a configuration problem. API calls themselves never reject for API or validation errors: the Promise resolves with an error object, as described under Response basics. (The webhook helper parseWebhook() is separate, and does throw on a body it cannot read; see Webhooks.)

Refresh or invalidate your Auth Token

  1. Sign in to the BestSMS Dashboard
  2. Open the 'Users' page
  3. Select the user that has API access
  4. Open its 'API' tab
  5. Press the refresh/recycle button beside the Auth Token
  6. Give the new token to every application that uses it

Refreshing disables the previous token at once. Applications still using it fail authentication until they are switched to the new token.

Environment Variables

bestsms can also be configured through environment variables, which suits CI pipelines and containers, or any situation where credentials should stay out of your source code. Only operating-system environment variables are read. No .env file is loaded for you, so a loader like dotenv is needed if your script relies on one.

VariablePurposeDefault
BESTSMS_AUTH_TOKENSupplies the Auth Token when AuthToken is not passed to the constructor. A token passed explicitly always wins.(none: the constructor throws if the token is still empty)
BESTSMS_API_URLChanges the base URL requests are sent to. The URL constructor argument, when given, wins over this variable. The URL must be https:// unless BESTSMS_ALLOW_INSECURE_HTTP is set.https://api.bestsms.com.au/api/v3.00
BESTSMS_ALLOW_INSECURE_HTTPSetting the variable to "true" (lowercase, nothing else) permits a plain http:// URL, which helps with a local or staging server that has no TLS. With it unset, the SDK will not put the Bearer token on a non-HTTPS connection, and the call resolves with an error response.(unset, HTTPS enforced)
BESTSMS_UNSAFE_IGNORE_SSLSetting the variable to "true" (lowercase, nothing else) turns off TLS certificate checking, for a local server using a self-signed certificate. It is refused when NODE_ENV=production: the call resolves with an error response, because accepting an invalid certificate would expose the Bearer token to interception.(unset, certificates verified)
import { BestSMS } from 'bestsms';

// No AuthToken passed, so BESTSMS_AUTH_TOKEN is read from the environment
const client = new BestSMS();

// An explicit token and a local test server
const local = new BestSMS({
    AuthToken: "your-token",
    URL: "https://localhost:5001/api/v3.00"
});

Setting environment variables

Windows

For the open PowerShell window only:

$env:BESTSMS_AUTH_TOKEN = "your-auth-token"

For the open Command Prompt (cmd.exe) window only:

set BESTSMS_AUTH_TOKEN=your-auth-token

To keep it for your Windows user account (terminals and processes started afterwards will see it):

[System.Environment]::SetEnvironmentVariable("BESTSMS_AUTH_TOKEN", "your-auth-token", "User")

Prefer the graphical route? Search Windows Settings for "Environment Variables" and pick Edit environment variables for your account.

Linux

For the current shell session only:

export BESTSMS_AUTH_TOKEN="your-auth-token"

To keep it for your user account, add that export line to ~/.bashrc (bash), ~/.zshrc (zsh) or ~/.profile, then start a new shell or source the file.

For a systemd service, set the variable inside the unit file:

[Service]
Environment="BESTSMS_AUTH_TOKEN=your-auth-token"
macOS

For the current shell session only:

export BESTSMS_AUTH_TOKEN="your-auth-token"

To keep it for your user account, add that export line to ~/.zshrc (the default shell on current macOS) or ~/.bash_profile, then open a new terminal or source the file.

The other three variables are set in exactly the same way, with only the name changing.

Response basics

Whichever method you call, the Promise settles with one of two kinds of object. A successful call yields a typed result object (for example MessagingApiSuccessResponseDTO with a MessageID) whose Result is "Success". A failed call yields an ErrorResponseDTO, a class exported by the package, with a Result string and an ErrorMessage that is always a string[], empty or not. If the API replies with a single string as ErrorMessage, the SDK wraps it in a one-element array for you.

import { BestSMS, ErrorResponseDTO } from 'bestsms';

const client = new BestSMS();

const response = await client.Messaging.SMS.SendMessage({
    Message: "Your appointment is confirmed for 10:30am tomorrow.",
    Destination: "+61491570006"
});

if (response instanceof ErrorResponseDTO) {
    for (const error of response.ErrorMessage) {
        console.log(`- Error=${error}`);
    }
} else {
    console.log(`Accepted - MessageID: ${response.MessageID}`);
}

Testing response.Result === "Success" narrows the type in exactly the same way. A failed response has one of these Result values:

  • "Error": the SDK itself produced the failure, either because validation stopped the call before any request went out, or because of a transport problem (an unreachable host, a timeout, a body that is not JSON).
  • "Failed" or "Unauthorized": reported by the API.
  • Other API codes such as "RecordNotFound" (a 404) can also arrive. The declared type lists only the three values above, so compare Result as a plain string or use instanceof ErrorResponseDTO.

A reply is treated as a success when the HTTP status is 200 or when the body's Result is "Success". Keep in mind that acceptance is not delivery: a successful send tells you BestSMS took the message. To learn whether it reached the handset, poll with Reports or listen for Webhooks.

The internal Result enum is not exported from 'bestsms', so compare against the string literals above. The response DTO types (StatusApiResponseDTO, ActionApiResponseDTO, ContactApiResponseDTO, OptOutListApiResponseDTO and so on) are exported as types, and can be used to annotate your own functions:

import type { StatusApiResponseDTO } from 'bestsms';

function summarise(status: StatusApiResponseDTO): string {
    return `${status.JobNum}: ${status.Success} delivered, ${status.Failed} failed`;
}

Request validation

Before sending anything, the SDK checks the arguments it can verify locally. A missing or empty required value, such as the MessageID for Reports.Status.Poll(...) or Actions.Abort.SendRequest(...), does not throw and does not cause an HTTP request. Instead the Promise resolves to an ErrorResponseDTO with Result: "Error" and a readable ErrorMessage such as ["Missing MessageID"].

import { ErrorResponseDTO } from 'bestsms';

const response = await client.Reports.Status.Poll({ MessageID: "" });

if (response instanceof ErrorResponseDTO) {
    console.log(response.ErrorMessage); // ["Missing MessageID"]
}

The same applies to other local checks: a ToNumber that is not a mobile number, an unparseable SendTime, or a paging value out of range. The SMS, Reports and Actions sections list the checks that belong to each call. The Addressbook and OptOut calls take the same approach.

Common Response Values

Two exported TypeScript enums are used on the request side, and both can be imported from 'bestsms':

import { WebhookCallbackFormat, NotificationType } from 'bestsms';
EnumMembersUsed for
WebhookCallbackFormatJSON, XML, POST, GETThe format used when events are delivered to the WebhookCallbackURL of a SendMessage(...) call. You must set it whenever a WebhookCallbackURL is present.
NotificationTypeNone, Webhook, EmailSelects how SendMessage(...) notifies you of the outcome.

Mode is not an enum. It is typed as the string literal 'Test', which is the only value it accepts.

Result fields that describe job and recipient state are declared as plain string, so no enum is available for them. The values the API returns are:

FieldValuesUsed on
JobStatus"Pending", "Delayed", "Completed", "CreditHold", "Unknown"The JobStatus of a whole job in Status and SMSReply results. Abort and Reschedule results report the same set through Status.
Status (per recipient)"Success", "Failed", "Pending"The Status of each entry in Recipients.
Type (per recipient)"SMS"The Type of each entry in Recipients.

The Destination Model

There is no destination class to construct. SMS describes a recipient with the plain interface ISMSDestination, so you simply write object literals. IMessagingDestination is exported as well, and is currently an alias of ISMSDestination.

const destinations = [
    { ToNumber: "+61491570006", FirstName: "Alice" },
    { ToNumber: "+61491570007", FirstName: "Bob" },
];

await client.Messaging.SMS.SendMessage({
    Message: "Hello [[FirstName]], your order is on its way.",
    Destinations: destinations
});

These are the fields of ISMSDestination. All of them are optional strings:

FieldTypeDescription
ToNumberstringThe mobile number to message. The SDK checks it as a mobile number by trying New Zealand numbering, then Australian numbering, then international format; +61 numbers in E.164 form are the safest choice.
ContactIDstringAddresses an Addressbook contact rather than typing a number.
GroupIDstringAddresses all members of an Addressbook group.
GroupCodestringIdentifies an Addressbook group by its code.
EmailAddressstringPersonalisation field carried with the destination.
MainPhonestringPersonalisation field carried with the destination.
AttentionstringFills the merge tag [[Attention]].
CompanystringFills the merge tag [[Company]].
FirstNamestringFills the merge tag [[FirstName]].
LastNamestringFills the merge tag [[LastName]].
Custom1–Custom9stringFill the merge tags [[Custom1]]–[[Custom9]]. The REST API definition itself documents Custom1 to Custom4.

Shorthand for simple sends

When a full Destinations array is overkill, SendMessage(...) accepts four single-field shorthands: Destination, ToNumber, ContactID and GroupID. Each one may hold several comma-separated values, and every value becomes its own destination:

await client.Messaging.SMS.SendMessage({
    Message: "Depot closed Monday for the public holiday.",
    Destination: "+61491570006,+61491570007"
});

Adding recipients with the builder

AddRecipient(...) on client.Messaging.SMS accepts a string (treated as { ToNumber }), an ISMSDestination, or an array mixing both, and it returns the channel so calls can be chained. Recipients added this way come first, followed by any in the Destinations you give to SendMessage(...):

const response = await client.Messaging.SMS
    .AddRecipient("+61491570006")
    .AddRecipient({ ToNumber: "+61491570007", FirstName: "Alice", Company: "Example Company" })
    .SendMessage({ Message: "Hello [[FirstName]], thanks for your order." });

Remember that a destination which is not a valid mobile number resolves to an error (Invalid ToNumber - must be mobile number - ...) before any request is made, and that a send with no destinations at all resolves to Empty Destination(s). See SMS for the complete argument list.

Actions & Reports

The SMS channel object only sends. client.Messaging.SMS has no Status, Reply, Received, Abort or Reschedule methods of its own, so polling goes through Reports (client.Reports.Status.Poll(...), client.Reports.SMSReply.Poll(...) and client.Reports.SMSReceived.Poll(...)) and changes to a delayed message go through Actions (client.Actions.Abort.SendRequest(...) and client.Actions.Reschedule.SendRequest(...)):

const response = await client.Actions.Reschedule.SendRequest({
    MessageID: "ID123456",
    SendTime: "2026-11-03T09:00"
});

SMS

The SMS module of the bestsms package sends text messages through the BestSMS REST API, to one recipient or to thousands. Texting works in both directions. You can watch each message move through delivery (covered under Poll for status further down) and collect what people text back, either by polling (covered under Poll for inbound SMS) or by letting BestSMS push each reply to your server, which is covered in Webhooks.

Every call is a Promise. It resolves to either a typed success object or an ErrorResponseDTO, and it does not reject for validation or API problems. Check the outcome with instanceof ErrorResponseDTO; Getting Started explains the result values.

Message Body Tokens

On top of the personalisation tokens listed under Destination Fields, the text in Message can carry these inline tokens, which BestSMS expands when it sends:

  • [[Link:https://example.com/page]]: swaps the URL for a short link and records who clicks it.
  • [[File1]]: is replaced by a link pointing at your first attached file, whether it came in through Attachments or AddAttachment(...). Additional files use [[File2]], [[File3]] and so on.
  • [[REPLY]]: becomes a tappable link the recipient can use to answer, including from handsets that have no SMS reply feature of their own.
import { BestSMS } from 'bestsms';

const client = new BestSMS({ AuthToken: "[Your Auth Token]" });

await client.Messaging.SMS.SendMessage({
    Destination: "+61491570006",
    Message: "Your invoice is ready: [[Link:https://example.com/invoice/123]] Reply here: [[REPLY]]. Reply STOP to opt out.",
    Mode: "Test" // nothing is delivered in Test mode
});

Quick Example

If you leave AuthToken out, the constructor falls back to the BESTSMS_AUTH_TOKEN environment variable and throws if neither is present.

import { BestSMS, ErrorResponseDTO } from 'bestsms';

const client = new BestSMS({ AuthToken: "[Your Auth Token]" });

const response = await client.Messaging.SMS.SendMessage({
    Destination: "+61491570006",
    Message: "Test SMS",
    Reference: "Test",
    Mode: "Test" // nothing is delivered in Test mode
});

if (response instanceof ErrorResponseDTO) {
    console.error(response.Result, response.ErrorMessage);
} else {
    console.log(`Success - MessageID: ${response.MessageID}`);
}

Parameters

These are the fields of the ISMSArgs object accepted by SendMessage(...).

ParameterTypeRequiredDescription
MessagestringYes*The text to send, up to 1000 characters. Personalisation tokens such as [[FirstName]] and [[Custom1]] are allowed.
TemplateIDstringYes*UUID of a message template saved in your Dashboard, used in place of Message. If you supply both, Message is used.
DestinationsISMSDestination[]Yes†One or more recipients. Details are under Destination Fields below.
DestinationstringYes†Stands in for Destinations: [{ ToNumber }]. Separate several numbers with commas and each becomes its own destination.
ToNumberstringYes†Identical to Destination.
ContactIDstringYes†Stands in for Destinations: [{ ContactID }], which targets one Addressbook contact. Commas separate multiple IDs.
GroupIDstringYes†Stands in for Destinations: [{ GroupID }], which targets every member of an Addressbook group. Commas separate multiple IDs.
MessageIDstringNoYour own tracking ID, up to 40 characters. BestSMS generates a UUID when you leave it out. Duplicates are accepted, so use a fresh value for each request if you need to tell them apart.
ReferencestringNoA readable label of your choice, up to 100 characters. It comes back in reports and webhooks.
NotificationTypeNotificationTypeNoHow status updates and inbound replies for this message reach you: NotificationType.None, NotificationType.Webhook or NotificationType.Email. Omit it to use your User's default settings.
WebhookCallbackURLstringNoThe full https:// address that receives this message's webhooks, up to 500 characters. When it is set, WebhookCallbackFormat becomes mandatory.
WebhookCallbackFormatWebhookCallbackFormatYes‡Body format of the callback: WebhookCallbackFormat.JSON, XML, POST or GET. The receivers in Webhooks expect JSON.
ReportTostringNoEmail address that receives a delivery report once the message completes.
SendTimestringNoHolds the message back until this moment, written YYYY-MM-DDThh:mm (or YYYY-MM-DD hh:mm) in the user's local timezone. It is passed on exactly as written. Leave it out to send immediately.
TimezonestringNoThe Windows timezone name that SendTime is read in, for example "AUS Eastern", "E. Australia" or "W. Australia". Your User's default timezone applies when it is omitted.
SubAccountstringNoSub-account used for reporting and billing, up to 40 characters.
DepartmentstringNoDepartment used for reporting and billing, up to 40 characters.
FromNumberstringNoThe sender ID the recipient sees. Write it in E.164 format without the leading +, for example "61408080909". Some countries override this value.
SMSEmailReplystringNoEmail address that receives SMS reply notifications. A different Reply Notification rule on your account may take precedence.
SMSCustomPageIDstringNoUUID of a Custom Page template. The ID appears when you open the template in the Dashboard.
CharacterConversionbooleanNoRewrites multi-byte characters as their GSM equivalents (for example © becomes (C)), which shrinks the message. Turning it on rules out emojis and other special characters. The default is false.
Mode'Test'No"Test" simulates a successful submission. Nothing is delivered and nothing is charged, yet you still receive a MessageID and any configured webhooks still fire. It is the only value the SDK accepts; leave it out for a live send.
Attachmentsstring[]NoPaths of local files. The SDK reads each one, base64-encodes it and sends it as Files: [{ Name, Data }]; the paths themselves never leave your machine. Put [[File1]], [[File2]] and so on in Message where each link should appear.

*Supply Message or TemplateID.
†Recipients come from Destinations, from the Destination/ToNumber/ContactID/GroupID shorthand, or from AddRecipient(...) on the builder. All of these are combined, so you can use them together.
‡Required only when WebhookCallbackURL is set.

The text, destination and file limits above (Message 1000 characters, MessageID 40, Reference 100, WebhookCallbackURL 500, SubAccount/Department 40, a single Destination 60) are enforced by the BestSMS API. The SDK does not count characters beforehand, so an over-long value comes back as an error from the server. Each attached file may have a name of up to 255 characters and up to 1,398,102 base64 characters of data (about 1 MB of file).

Checks made before anything is sent: when one of these fails, SendMessage(...) resolves to an ErrorResponseDTO with Result set to "Error" and no request goes out. The checks are: neither Message nor TemplateID given; no destination at all; a ToNumber that is not a valid mobile number (the SDK tries New Zealand then Australian numbering, then international format, so write numbers as +61...); WebhookCallbackURL without a valid WebhookCallbackFormat; a SendTime that doesn't parse as a date; a Mode other than Test; and an attachment path that does not exist. Only ToNumber values are checked this way. ContactID and GroupID are passed straight to the API.

Two calling styles: you can hand every field above to SendMessage({ ... }) as a single object. Or you can queue recipients and files first with client.Messaging.SMS.AddRecipient(...) and AddAttachment(...), each of which returns the same object so calls chain, and then finish with SendMessage({ ... }). Items queued on the builder go first, followed by any in the object you pass, and your object and its arrays are never modified. The queue is emptied by each SendMessage(...) call. Because the queue lives on the shared client.Messaging.SMS object, write the whole chain, including SendMessage, as one expression instead of adding recipients and then awaiting something unrelated before sending. AddAttachment(...) looks for the file straight away. A missing path is remembered, and the next SendMessage(...) resolves to { Result: "Error", ErrorMessage: ["Attachment file not found: <path>"] }.

Destination Fields (ISMSDestination)

Each entry of Destinations is an ISMSDestination object. When you give AddRecipient(...) a plain string, it is stored as { ToNumber: "<the string>" }; it also takes an ISMSDestination or an array mixing both. The interface lists a fixed set of fields, so the compiler flags a misspelt property in an object literal. That check exists only at build time, though: nothing stops an unknown key at runtime.

FieldDescription
ToNumberThe recipient's mobile number, for example "+61491570006". It has to pass the mobile-number check described above.
EmailAddressAllowed by the type and the API schema, but an SMS is delivered to ToNumber.
MainPhoneAllowed by the type and the API schema, but an SMS is delivered to ToNumber.
AttentionSupplies the [[Attention]] token and the name used in reports.
FirstName / LastName / CompanySupply the [[FirstName]], [[LastName]] and [[Company]] tokens.
Custom1–Custom9Free per-recipient values, used as [[Custom1]] through [[Custom9]]. The API specification itself lists Custom1 to Custom4 for destinations, while the SDK type allows nine.
ContactIDAn Addressbook contact. The message goes to that contact and not to a number you typed.
GroupIDAn Addressbook group. Every member of the group receives the message.
GroupCodeFinds an Addressbook group by its code (up to 100 characters) in place of GroupID.

Code Samples

Single destination shorthand

The shortest route to a send is a message plus a Destination. Put several numbers in the string, separated by commas, to reach more than one person this way.

const response = await client.Messaging.SMS.SendMessage({
    Destination: "+61491570006,+61491570007",
    Message: "The office is closed today.",
    Mode: "Test" // nothing is delivered in Test mode
});

Multiple destinations, via the builder

Use AddRecipient(...) when recipients are gathered in several steps, or when raw numbers need to sit alongside Addressbook contacts and groups. Each call can take a string, an object, or a list that mixes the two.

const response = await client.Messaging.SMS
    .AddRecipient("+61491570006")
    .AddRecipient({ ToNumber: "+61491570007", FirstName: "Bob" })
    .AddRecipient([{ ContactID: "[Contact ID]" }, { GroupID: "[Group ID]" }])
    .SendMessage({
        Message: "Hi [[FirstName]]!",
        Reference: "Test SMS - Builder sample",
        Mode: "Test" // nothing is delivered in Test mode
    });

Addressbook ContactID/GroupID

When only one group or contact is involved, the top-level GroupID or ContactID is enough. To mix several groups, contacts and loose numbers in one send, list them all in Destinations.

// Everyone in one Addressbook group
const toGroup = await client.Messaging.SMS.SendMessage({
    Message: "Reminder: your subscription renews tomorrow.",
    GroupID: "[Group ID]",
    Mode: "Test" // nothing is delivered in Test mode
});

// One Addressbook contact
const toContact = await client.Messaging.SMS.SendMessage({
    Message: "Hi [[FirstName]], your order has shipped!",
    ContactID: "[Contact ID]",
    Mode: "Test"
});

// Groups, contacts and a plain number together
const bulk = await client.Messaging.SMS.SendMessage({
    Message: "Reminder: your subscription renews tomorrow.",
    Destinations: [
        { GroupID: "[Group ID 1]" },
        { GroupID: "[Group ID 2]" },
        { ContactID: "[Contact ID 1]" },
        { ToNumber: "+61491570006" }
    ],
    Mode: "Test"
});

Bulk send with per-destination personalisation

A single message can go to many people with each copy filled in for its own recipient, by putting the personalisation values on each ISMSDestination.

const response = await client.Messaging.SMS.SendMessage({
    Message: "Hi [[FirstName]], your appointment is on [[Custom1]].",
    Destinations: [
        { ToNumber: "+61491570006", FirstName: "Alice", Custom1: "Monday 3pm" },
        { ToNumber: "+61491570007", FirstName: "Bob", Custom1: "Tuesday 10am" }
    ],
    Mode: "Test" // nothing is delivered in Test mode
});

Send from a template, with sender options

When a message template already exists in your Dashboard, pass its TemplateID instead of Message. The sample also sets the sender ID, the address that gets reply notifications, and character conversion.

const response = await client.Messaging.SMS.SendMessage({
    TemplateID: "[Template ID]",
    Destinations: [{ ContactID: "[Contact ID]" }],
    FromNumber: "61408080909", // E.164 without the leading +
    SMSEmailReply: "[email protected]",
    CharacterConversion: true,
    Mode: "Test" // nothing is delivered in Test mode
});

Send a file via MessageLink

Name a local file in Attachments (or call AddAttachment(...)) and mention it in the text as [[File1]]. What the recipient receives is an SMS holding a link; the file is not embedded.

Security note: attachment paths are read from disk as given, with only a check that the file exists. Never build a path from unchecked user input. Resolve it against a folder you trust, and reject anything that escapes that folder, which is the same discipline any server-side file access calls for.

const response = await client.Messaging.SMS.SendMessage({
    Message: "Here is the photo you asked for: [[File1]]",
    Destination: "+61491570006",
    Attachments: ["path/to/photo.jpg"],
    Mode: "Test" // nothing is delivered in Test mode
});

// The same thing with the builder, and two files
const twoFiles = await client.Messaging.SMS
    .AddAttachment("path/to/photo.jpg")
    .AddAttachment("path/to/receipt.pdf")
    .SendMessage({
        Message: "Your files: [[File1]] [[File2]]",
        Destination: "+61491570006",
        Mode: "Test"
    });

Scheduled send with webhook callback

Pair SendTime/Timezone, which hold the message back, with WebhookCallbackURL, which tells you the moment it has finished. You then have no need to poll for status.

import { NotificationType, WebhookCallbackFormat } from 'bestsms';

const response = await client.Messaging.SMS.SendMessage({
    Message: "Don't forget your appointment.",
    Destination: "+61491570006",
    SendTime: "2026-12-31T09:00",
    Timezone: "AUS Eastern",
    NotificationType: NotificationType.Webhook,
    WebhookCallbackURL: "https://yourapp.example.com/webhooks/bestsms/result",
    WebhookCallbackFormat: WebhookCallbackFormat.JSON,
    Mode: "Test" // nothing is delivered in Test mode
});

Poll for status

At any point after sending you can ask how the job is going and what happened to each recipient. Reports live on their own facade (see Reports), so the call is client.Reports.Status.Poll(...). BestSMS suggests giving up if a message still has no result after 6 hours, and polling no more often than once a second.

import { ErrorResponseDTO } from 'bestsms';

// MessageID as returned by SendMessage
const status = await client.Reports.Status.Poll({ MessageID: "[Message ID]" });

if (status instanceof ErrorResponseDTO) {
    console.error(status.ErrorMessage.join("; "));
} else {
    console.log(`JobStatus: ${status.JobStatus}`);
    for (const recipient of status.Recipients) {
        console.log(` -> ${recipient.Destination}: ${recipient.Status} (${recipient.Result})`);
    }
}

You may also pass RecordsPerPage (default 100) and Page (default 1) to move through the Recipients of a large job. The SDK rejects a RecordsPerPage outside 1–999 and a Page below 1 with Result: "Error". The API specification does not document paging parameters for this endpoint, so look at TotalRecords and PageCount in the response to see whether paging took effect. Passing an empty MessageID also gives an "Error" result, as the note on required IDs in Getting Started describes.

Each recipient returned by Status.Poll(...) carries its replies in SMSReplies when the program runs, yet the declared type of Recipients leaves that property out. The next sample shows one way to get typed access. Otherwise use Reports.SMSReply.Poll(...), shown further down, whose types already include it.

import { ErrorResponseDTO } from 'bestsms';
import type { SMSReplyApiResponseDTO } from 'bestsms';

type ReplyRecipient = SMSReplyApiResponseDTO['Recipients'][number];

const status = await client.Reports.Status.Poll({ MessageID: "[Message ID]" });

if (!(status instanceof ErrorResponseDTO)) {
    for (const recipient of status.Recipients) {
        for (const reply of (recipient as ReplyRecipient).SMSReplies) {
            console.log(`    reply: ${reply.MessageText}`);
        }
    }
}

Poll for inbound SMS

This collects the replies that arrived during the last TimePeriod minutes (1–1440, default 1440) and is the polling alternative to a webhook. To use a calendar window instead, give both DateFrom and DateTo as YYYY-MM-DDThh:mm in your local time, covering at most 7 days; once both are present, TimePeriod is ignored. Giving only one of the two dates, or a TimePeriod outside its range, resolves to an ErrorResponseDTO and never throws. RecordsPerPage and Page work as they do for status (default 100 and 1), and one call returns just the page you asked for. Polling more than once a second risks hitting the rate limit.

import { ErrorResponseDTO } from 'bestsms';

const received = await client.Reports.SMSReceived.Poll({
    TimePeriod: 1440 // look back this many minutes
});

if (!(received instanceof ErrorResponseDTO)) {
    for (const message of received.Messages) {
        console.log(`From ${message.From}: ${message.MessageText}`);
    }
}

Poll for replies to a specific message

client.Reports.SMSReply.Poll(...) requests the same data as Status.Poll(...), since both read GET /sms/{MessageID}. Its response type declares SMSReplies on every recipient, so no cast is needed.

import { ErrorResponseDTO } from 'bestsms';

const replies = await client.Reports.SMSReply.Poll({ MessageID: "[Message ID]" });

if (!(replies instanceof ErrorResponseDTO)) {
    for (const recipient of replies.Recipients) {
        for (const reply of recipient.SMSReplies) {
            console.log(`${recipient.Destination} replied: ${reply.MessageText}`);
        }
    }
}

Actions

A message that is still waiting to go out can be rescheduled or aborted. Both live on the client.Actions facade and are described in Actions; each takes an object holding the MessageID.

Reschedule

Give a delayed message a new SendTime, written YYYY-MM-DDThh:mm (or YYYY-MM-DD hh:mm). It is interpreted in the timezone the message already has.

import { ErrorResponseDTO } from 'bestsms';

const rescheduled = await client.Actions.Reschedule.SendRequest({
    MessageID: "[Message ID]",
    SendTime: "2026-12-31T12:00"
});

if (!(rescheduled instanceof ErrorResponseDTO)) {
    console.log(`Action: ${rescheduled.Action}, Status: ${rescheduled.Status}`);
}

Abort

Cancels a delayed message so that it is never sent.

await client.Actions.Abort.SendRequest({ MessageID: "[Message ID]" });

Response

The success types are exported from the package as types, for example import type { StatusApiResponseDTO } from 'bestsms'. ErrorResponseDTO is exported as a class, which is what lets instanceof work.

SendMessage(...) response (MessagingApiSuccessResponseDTO)

A success only tells you BestSMS accepted the request. It does not confirm delivery, so use status polling or the send-result webhook for that.

FieldTypeDescription
ResultstringAlways "Success" on this type. See Getting Started for failures.
MessageIDstring | undefinedIdentifier of the message that was submitted. The API defines no other field for this response.

Error response (ErrorResponseDTO)

FieldTypeDescription
Resultstring"Error" for a client-side check, a network problem or an unreadable response; "Failed", "Unauthorized" or, for an ID the API can't find, "RecordNotFound" when the API said so. Covered in Getting Started.
ErrorMessagestring[]The reasons, always as an array. A plain string from the API is wrapped into a one-item array.

Status.Poll(...)/SMSReply.Poll(...) response (StatusApiResponseDTO/SMSReplyApiResponseDTO)

Both types hold the same fields. They differ only in how Recipients is typed.

FieldTypeDescription
ResultstringAlways "Success" on this type. See Getting Started.
MessageIDstringIdentifies which message the report is about.
JobStatusstringState of the job as a whole. The API documents "Pending", "Delayed", "Completed", "CreditHold" and "Unknown".
JobNumstringBestSMS's own reference number for the job.
AccountstringThe BestSMS account the job belongs to.
SubAccount / DepartmentstringEchoed back from the send request.
ReferencestringReturned as given in Reference.
CreatedTimeLocal / CreatedTimeUTC / CreatedTimeUTC_RFC3339stringCreation time of the job: local, UTC, and RFC 3339 UTC.
DelayedTimeLocal / DelayedTimeUTC / DelayedTimeUTC_RFC3339stringThe planned send time. It appears only for a delayed send.
TimezonestringTimezone name applied to the scheduling.
CountnumberNumber of recipients in the job.
CompletenumberRecipients that have completed successfully.
Success / FailednumberRecipients delivered / recipients that failed.
Pricestring | nullCost of the job before tax and plan credits. null when there is no cost.
TotalRecords / RecordsPerPage / PageCount / PagenumberPaging details for Recipients.
RecipientsRecipientDTO[] / SMSReplyRecipientDTO[]One entry per recipient, described below. Status.Poll(...) declares the first type and SMSReply.Poll(...) the second.
Recipient object (each entry in Recipients)
FieldTypeDescription
TypestringThe kind of recipient, "SMS" here.
DestSeqstringPosition of this recipient in the job, increasing with each destination.
DestinationstringThe recipient's phone number.
ContactIDstringAddressbook contact reference, present when the send used ContactID/GroupID.
StatusstringDelivery state for this recipient: "Success", "Failed" or "Pending".
ResultstringFinal delivery outcome in readable form, for example "Delivered" or "Bad Number".
MessageTextstringThe text sent to this recipient. It is absent when the message never reached the mobile network.
SentTimeLocal / SentTimeUTC / SentTimeUTC_RFC3339stringWhen the message went to this recipient.
Attention / Company / Custom1–Custom9stringPersonalisation values echoed back as you submitted them. See Destination Fields above.
Pricestring | nullCost for this recipient.
SMSRepliesSMSReplyRecipientSMSReplyDTO[]Replies from this recipient (see the table below). Declared on the SMSReply.Poll(...) type only, but filled in for Status.Poll(...) too (see the note above).
SMSReplies object (each entry in Recipients[].SMSReplies)
FieldTypeDescription
ReceivedIDstringUnique identifier of the reply.
ReceivedTimeLocal / ReceivedTimeUTC / ReceivedTimeUTC_RFC3339stringWhen the reply arrived.
TimezonestringTimezone name that applies to ReceivedTimeLocal.
FromstringPhone number of the person who replied, in E.164 format.
MessageTextstringThe reply text.

SMSReceived.Poll(...) response (SMSReceivedApiResponseDTO)

FieldTypeDescription
ResultstringAlways "Success" on this type. See Getting Started.
TotalRecords / RecordsPerPage / PageCount / PagenumberPaging details for Messages, driven by the RecordsPerPage/Page you passed. The SDK never fetches further pages for you.
MessagesSMSReceivedDTO[]The inbound SMS messages, described below.
Message object (each entry in Messages)
FieldDescription
ReceivedIDUnique identifier of the message.
MessageIDThe outbound message being answered. It is there only when a matching reply was found.
JobNumJob number of the send this message answers, where there is one.
SubAccount / DepartmentBilling codes inherited from the send request.
ReceivedTimeLocal / ReceivedTimeUTC / ReceivedTimeUTC_RFC3339When the message arrived, in three formats.
FromPhone number of the sender, in E.164 format.
ContactIDAddressbook contact reference, present when the sender matched a contact.
MessageTextThe text of the message.
TimezoneTimezone name that applies to ReceivedTimeLocal.
VersionVersion of the payload format.

Reschedule/Abort response (ActionApiResponseDTO)

FieldTypeDescription
ResultstringAlways "Success" on this type. See Getting Started.
ActionResultstringOutcome of the action request, which is not the same thing as the outcome of the message.
MessageIDstringThe message the action was applied to.
JobNumstringNumber of the job the action was applied to.
StatusstringState of the job after the action.
ActionstringThe action that ran, for example "Reschedule".

Reports

All polling lives under client.Reports. The SMS channel object (client.Messaging.SMS) is only for sending, and has no Status(...), Reply(...) or Received(...) method. Whenever you need to know how a message went, or want to fetch inbound texts, come here.

Three properties are available on client.Reports: .Status, .SMSReply and .SMSReceived. Each is one long-lived request object, and its internal state is cleared after every Poll(...), which means you can call the same property again and again with different arguments.

BestSMS asks that you poll no more than once per second. If a message still has no result after 6 hours, stop polling for it. Where you can, use Webhooks, which push the same information to you and are more efficient than polling.

Methods

MethodSignatureReturns
Status.PollPoll({ MessageID, RecordsPerPage?, Page? })StatusApiResponseDTO on success.
SMSReply.PollPoll({ MessageID, RecordsPerPage?, Page? })SMSReplyApiResponseDTO on success.
SMSReceived.PollPoll({ TimePeriod?, DateFrom?, DateTo?, RecordsPerPage?, Page? })SMSReceivedApiResponseDTO on success.

Every method resolves to an ErrorResponseDTO when something goes wrong. These reports cover SMS only, so none of them has a Channel argument. RecordsPerPage defaults to 100 and Page to 1; they are sent as the recordsPerPage and page query parameters. The SDK itself accepts a RecordsPerPage from 1 to 999 and a Page of at least 1, and rejects anything else before sending, but the REST API definition documents 5 to 100 for list calls, so staying inside that range is the safer habit. A call returns only the page you asked for: check PageCount and TotalRecords and request further pages yourself.

MessageID is mandatory for Status.Poll and SMSReply.Poll. When it is missing or empty, the Promise resolves to an ErrorResponseDTO ("Missing MessageID") and no request goes out. SMSReceived.Poll takes no MessageID.

Code Samples

Poll for message status

const response = await client.Reports.Status.Poll({ MessageID: 'ID123456' });

if (response.Result === 'Success') {
    console.log(`JobStatus: ${response.JobStatus}`);
    for (const recipient of response.Recipients) {
        console.log(` -> ${recipient.Destination}: ${recipient.Status} (${recipient.Result})`);
    }
}

Read every page of a large job

import { ErrorResponseDTO } from 'bestsms';

let page = 1;
let pageCount = 1;

do {
    const response = await client.Reports.Status.Poll({
        MessageID: 'ID123456',
        RecordsPerPage: 100,
        Page: page,
    });

    if (response instanceof ErrorResponseDTO) {
        console.error(`Page ${page} failed:`, response.ErrorMessage);
        break;
    }

    pageCount = response.PageCount ?? 1;
    for (const recipient of response.Recipients) {
        console.log(`${recipient.Destination}: ${recipient.Status}`);
    }
    page++;
} while (page <= pageCount);

Poll for inbound SMS

This call lists SMS received during a period. It also returns texts that answer no particular outbound MessageID, so it can stand in for a webhook. Pass DateFrom and DateTo together, written as {date}T{time} in the API user's local time, spanning no more than 7 days. Leave them out and TimePeriod applies instead: a number of minutes from 1 to 1440, defaulting to 1440, which is the last 24 hours.

  • When both dates are present they are used and TimePeriod is not sent.
  • Supplying only one of the two dates resolves to an error ("DateFrom and DateTo must be supplied together").
  • A TimePeriod outside 1–1440 resolves to an error ("TimePeriod must be between 1 and 1440 minutes") and does not reach the server.
  • Each date needs a leading YYYY-MM-DD part and must parse as a date-time. It is sent unchanged. The 7-day limit is not checked in the SDK, so a longer range is left for the API to refuse.
const response = await client.Reports.SMSReceived.Poll({
    DateFrom: '2026-10-05T00:00:00',
    DateTo: '2026-10-12T00:00:00',
});

if (response.Result === 'Success') {
    for (const message of response.Messages) {
        console.log(`Received from ${message.From}: ${message.MessageText}`);
    }
}

For a rolling window instead, such as every text received in the past hour:

const recent = await client.Reports.SMSReceived.Poll({ TimePeriod: 60 });

Poll for replies to one message

SMSReply.Poll(...) sends exactly the same request as Status.Poll(...) (GET /sms/{MessageID}), and so the result has the same fields. What differs is the declared type: its Recipients are typed with a SMSReplies array, which Status.Poll does not expose to the compiler. Replies show up in Recipients[].SMSReplies. Use SMSReply.Poll whenever your code reads replies, so that TypeScript knows about them.

const response = await client.Reports.SMSReply.Poll({ MessageID: 'ID123456' });

if (response.Result === 'Success') {
    for (const recipient of response.Recipients) {
        for (const reply of recipient.SMSReplies) {
            console.log(`${recipient.Destination} replied: ${reply.MessageText}`);
        }
    }
}

Response

Each response has a Result field. Check response.Result === "Success" (or use instanceof ErrorResponseDTO) before reading any other field. Details are in Getting Started.

Status.Poll(...) and SMSReply.Poll(...)

Both resolve to an object with these fields. Only the type of Recipients differs, as explained in the recipient tables below.

FieldTypeDescription
Resultstring"Success" for a successful call. See Getting Started.
MessageIDstringThe message that these details describe.
JobStatusstringThe state of the job: "Pending", "Delayed", "Completed", "CreditHold" or "Unknown". See Common Response Values.
JobNumstringThe internal job number used by BestSMS.
Account / SubAccount / DepartmentstringAccount and billing-separation values recorded for the job.
ReferencestringYour own reference, if you gave one at send time.
CreatedTimeLocal / CreatedTimeUTC / CreatedTimeUTC_RFC3339stringThe moment the job was created.
DelayedTimeLocal / DelayedTimeUTC / DelayedTimeUTC_RFC3339stringThe time the job is delayed until, for a delayed or rescheduled job.
TimezonestringThe Timezone used for the local times above.
CountnumberHow many recipients the job has.
CompletenumberHow many recipients have been handled to date.
SuccessnumberHow many recipients succeeded.
FailednumberHow many recipients failed.
Pricestring | nullWhat the whole job cost.
TotalRecordsnumberRecipients matching in total, across every page.
RecordsPerPagenumberThe page size applied to this response.
PageCountnumberHow many pages there are in total.
PagenumberThe page held by this response.
RecipientsRecipientDTO[] (Status.Poll)
SMSReplyRecipientDTO[] (SMSReply.Poll)
One entry per recipient. At run time both methods build the entries with replies included; only the SMSReply.Poll declaration shows the SMSReplies field to the compiler.

Every entry in Recipients has these fields (none is guaranteed to be present):

FieldTypeDescription
TypestringThe kind of message, "SMS".
DestSeqstringPosition of the destination within the list; it increases with each destination.
DestinationstringThe address that was messaged, a mobile number in E.164 format such as +61491570006.
ContactIDstringThe Addressbook contact ID, when the send used a contact.
StatusstringOne of "Success", "Failed" or "Pending".
ResultstringThe final delivery outcome for the recipient, for example "Delivered" or "Bad Number".
MessageTextstringThe text that was sent. It is present only when the message reached the mobile network and was not blocked beforehand.
SentTimeLocal / SentTimeUTC / SentTimeUTC_RFC3339stringThe time the message to this recipient went out.
Attention / CompanystringPersonalisation values handed back from the send request.
Custom1–Custom9stringCustom values handed back from the send request.
Pricestring | nullWhat this one message cost.

SMSReplyRecipientDTO adds one field to the list above:

FieldTypeDescription
SMSRepliesSMSReplyRecipientSMSReplyDTO[]Replies this recipient sent back. An empty array when there are none.

Fields of each reply (SMSReplyRecipientSMSReplyDTO):

FieldTypeDescription
ReceivedIDstringThe unique ID of the reply.
ReceivedTimeLocal / ReceivedTimeUTC / ReceivedTimeUTC_RFC3339stringThe time the reply arrived.
TimezonestringThe Timezone used for the local time.
FromstringThe number the reply was sent from.
MessageTextstringThe text of the reply.

SMSReceived.Poll(...)

The result is an SMSReceivedApiResponseDTO:

FieldTypeDescription
Resultstring"Success" for a successful call. See Getting Started.
TotalRecordsnumberMessages matching in total, across every page.
RecordsPerPagenumberThe page size applied to this response.
PageCountnumberHow many pages there are in total.
PagenumberThe page held by this response.
MessagesSMSReceivedDTO[]The received messages that matched.

Fields of each message (SMSReceivedDTO):

FieldTypeDescription
ReceivedIDstringThe unique ID of the received message.
MessageIDstringPresent when the message matches a reply: the MessageID of the outbound message it answers.
JobNumstringThe BestSMS job number of the outbound message it was matched to.
SubAccount / DepartmentstringBilling-separation values.
ReceivedTimeLocal / ReceivedTimeUTC / ReceivedTimeUTC_RFC3339stringThe time the message arrived.
FromstringThe sender's number, in E.164 format.
ContactIDstringThe Addressbook contact ID, when the sender matches a contact.
MessageTextstringThe text of the message.
TimezonestringThe Timezone used for the local time.
VersionstringThe version value returned by the API.

Failure

FieldTypeDescription
Resultstring"Error" for a failure found by the SDK (a missing MessageID, a paging value out of range, mismatched dates and so on), or a value the API reports, typically "Failed" or "Unauthorized". See Getting Started.
ErrorMessagestring[]For example ["Missing MessageID"], ["RecordsPerPage must be between 1 and 999"] or ["DateFrom and DateTo must be supplied together"].

Actions

client.Actions changes an SMS that has already been submitted but has not yet gone out. Identify the message by its MessageID and choose what to do with it. The SMS channel object (client.Messaging.SMS) only sends, so client.Actions is the one place these calls exist.

Two properties are available, .Abort and .Reschedule. Each is one long-lived request object whose internal state is cleared after every SendRequest(...), so reusing it for several independent calls is safe.

Methods

MethodSignatureDescription
Abort.SendRequestSendRequest({ MessageID })Cancels a previously delayed message (PATCH /sms/{MessageID}/abort).
Reschedule.SendRequestSendRequest({ MessageID, SendTime })Moves a previously delayed message to a new send time (PATCH /sms/{MessageID}/reschedule).

Both resolve to an ActionApiResponseDTO or, on failure, an ErrorResponseDTO. Only these two actions exist in the v3.00 library. Resubmit from the 2.x package is no longer offered, and there is no Channel argument, since every action targets SMS.

Validation happens in the SDK before anything is sent, and a failure resolves to an ErrorResponseDTO with Result: "Error" instead of throwing:

  • MessageID is required for both actions ("Missing MessageID").
  • SendTime is required for Reschedule ("Missing SendTime"). The value needs a leading YYYY-MM-DD part and must parse as a date-time; if it does not, the error reads "Unable to parse SendTime. Use YYYY-MM-DDThh:mm or YYYY-MM-DD hh:mm format."

A valid SendTime is passed on exactly as written, and it is read in the message's existing Timezone (for example AUS Eastern). The SDK does not check that the time is in the future or that the message is actually still delayed. Those decisions are left to the API, whose answer comes back as an ordinary error response.

Code Samples

Abort a delayed message

const response = await client.Actions.Abort.SendRequest({ MessageID: 'ID123456' });

if (response.Result === 'Success') {
    console.log(`Action: ${response.Action}, Status: ${response.Status}`);
}

Reschedule a delayed message

const response = await client.Actions.Reschedule.SendRequest({
    MessageID: 'ID123456',
    SendTime: '2026-11-03T09:00',
});

if (response.Result === 'Success') {
    console.log(`Action: ${response.Action}, Status: ${response.Status}`);
}

Handling a failure

A rejected action never throws. Branch on instanceof ErrorResponseDTO and read ErrorMessage, which is always an array:

import { ErrorResponseDTO } from 'bestsms';

const response = await client.Actions.Reschedule.SendRequest({
    MessageID: 'ID123456',
    SendTime: 'next Tuesday',
});

if (response instanceof ErrorResponseDTO) {
    console.log(response.ErrorMessage);
    // ["Unable to parse SendTime. Use YYYY-MM-DDThh:mm or YYYY-MM-DD hh:mm format."]
} else {
    console.log(`${response.MessageID}: ${response.ActionResult}`);
}

Response

Each response has a Result field. Check response.Result === "Success" (or use instanceof ErrorResponseDTO) before reading any other field. Details are in Getting Started.

Success

Either action returns an ActionApiResponseDTO. The reply counts as a success when the HTTP status is 200, or when its ActionResult is "Success".

FieldTypeDescription
Resultstring"Success". See Getting Started.
ActionResultstringHow the action request itself turned out. It describes the request and not the message.
MessageIDstringThe message the action acted on.
JobNumstringThe BestSMS job number of that message.
StatusstringThe job's state after the action: "Pending", "Delayed", "Completed", "CreditHold" or "Unknown". See Common Response Values.
ActionstringThe action that ran: "Abort" or "Reschedule".

Failure

Comes back when SDK validation stops the call (Result: "Error"), when the API rejects it (typically "Failed", "Unauthorized" or "RecordNotFound"), or when the request cannot be completed.

FieldTypeDescription
ResultstringThe failure code, as above. See Getting Started.
ErrorMessagestring[]For example ["Missing MessageID"] or ["Missing SendTime"].

Inbound Webhooks

A webhook is BestSMS calling you. When a message finishes sending, or when a customer texts you back, BestSMS posts the details to a URL on your server within moments. Your code no longer has to run Reports.Status.Poll(...) or Reports.SMSReceived.Poll(...) on a schedule, and delivery receipts and replies arrive without the constant background requests that polling needs.

"Inbound" is from your application's point of view: BestSMS sends an HTTP POST to a server you run. For one message, name that server with WebhookCallbackURL and WebhookCallbackFormat on SendMessage(...) (see SMS). Alternatively rely on your User's default webhook settings. The SDK does not receive anything itself. What it ships for this feature is a small kit for your own endpoint: the parseWebhook(...) and isInboundSMSWebhook(...) functions plus the types ISendResultWebhook, IInboundSMSWebhook, BestSMSWebhook and IWebhookHeaders, all exported from bestsms.

Security note: parseWebhook(...) does not authenticate anything. It only confirms that the body is a JSON object with a recognised Type. Every request carries an Authorization header plus X-Sender and X-Timestamp headers, and the payload itself names the sender in Sender and APIKey. Check these in your own handler before you act on a payload, otherwise anyone who learns your callback URL can post a forged event to it. The Configuring API Webhooks article explains how webhook authentication is set up on the BestSMS side. Whatever secret you configure there is what your handler should compare the Authorization header against.

All examples here parse a JSON body, so choose WebhookCallbackFormat.JSON when you send (or in your User's webhook settings). A callback in XML, POST or GET format would need parsing code of your own, because parseWebhook(...) reads JSON only. Events can reach you out of order, so decide by the contents of each event and not by when it arrived.

WebhookCallbackFormat

Import the WebhookCallbackFormat enum from bestsms to select how the callback body is encoded. Passing WebhookCallbackURL without a valid format makes SendMessage(...) resolve to an ErrorResponseDTO (Result: "Error") before any request is made.

ValueDescription
WebhookCallbackFormat.JSONA JSON body. This is the format the receivers on this page handle.
WebhookCallbackFormat.XMLAn XML body.
WebhookCallbackFormat.POSTA POST request in BestSMS's default encoding, which is neither JSON nor XML.
WebhookCallbackFormat.GETA GET request to your callback URL.

Request Headers

IWebhookHeaders describes the headers BestSMS adds to every webhook call. All three are optional in the type, because the SDK cannot guarantee what an arbitrary request carries.

HeaderTypeDescription
Authorizationstring | undefinedThe authentication token for the webhook, in Bearer ... form in the API specification's example.
X-Senderstring | undefinedThe sender's email address.
X-Timestampstring | undefinedWhen the request was made, as an RFC 3339 timestamp.

Payload Fields

BestSMS posts two kinds of event. The send result (ISendResultWebhook, Type "SMS") reports how one destination ended up. The inbound SMS (IInboundSMSWebhook, Type "SMSReply" or "SMSInbound") carries a text someone sent you. The two share most field names; the table notes where they part ways. Both types extend one common base and every field other than Type is optional in the typings. BestSMSWebhook is the union of the two.

FieldTypeDescription
VersionstringVersion of the webhook payload format, such as "v3.00".
SenderstringThe Sender value used to authenticate the webhook. It looks like an email address, and a unique Sender can be set up if you need one.
APIKeystring | nullThe token used to authenticate the webhook. A unique APIKey can be configured if you need one.
Type'SMS' / 'SMSReply' / 'SMSInbound'Always present. "SMS" marks a send result. On an inbound SMS, "SMSReply" means the text matched one of your outbound messages and "SMSInbound" means it did not.
DestinationstringThe recipient on a send result. On an inbound SMS it is the number that sent the text. Phone numbers are in E.164 format.
ContactIDstringAddressbook contact reference, present when the destination matched a contact.
ReceivedIDnull (send result) / string (inbound)Always null on a send result. On an inbound SMS it is the unique ID of the received message.
MessageIDstringThe outbound message the event relates to. On an inbound SMS it is set only if a matching outbound message was found.
SubAccountstringSub-account code copied from the send request.
DepartmentstringDepartment code copied from the send request.
JobNumberstringThe job number BestSMS assigned to the whole batch.
SentTimeLocal / SendTimeUTC / SentTimeUTC_RFC3339stringThe time of the event, given as local time, then UTC, then RFC 3339 UTC. Take care with the middle name: it is SendTimeUTC and not SentTimeUTC. It really does differ from its two neighbours, and both the API and the SDK types use it exactly this way.
Status'Success' / 'Failed' / 'Pending' (send result), 'RECEIVED' (inbound)The delivery state on a send result. On an inbound SMS it always reads "RECEIVED".
Resultstring (send result), 'RECEIVED' (inbound)The final delivery result on a send result, for example "delivered". On an inbound SMS it always reads "RECEIVED".
Messagenull (send result), string | null (inbound)Always null on a send result. On an inbound SMS it is the text that was received, up to 1000 characters.
Pricestring | nullWhat the message cost, ahead of tax and plan credits, or null if it was free of charge. See the Price note below.
DetailstringExtra information. A send result reports the number of SMS parts as SMSParts:2. On an inbound SMS you may see InputToNumber: followed by the sender's number in the format they used.
URLstringA related URL, where there is one.

parseWebhook and isInboundSMSWebhook

parseWebhook(body: string | object): BestSMSWebhook accepts the raw body text or an object your framework has already decoded. It throws an Error with a descriptive message when the text is not valid JSON, when the value is not a JSON object (an array, a string or null, for instance), or when Type is missing or not one of the three known values. For anything else it hands back the very same object, typed as BestSMSWebhook. It copies nothing, converts nothing and never checks the other fields, which is why code that depends on them should still be defensive. Keys the typings don't list simply stay on the object, so a field added by BestSMS later cannot make parsing fail.

isInboundSMSWebhook(webhook) is a type guard. It returns true for "SMSReply" and "SMSInbound" payloads and narrows the value to IInboundSMSWebhook. In its else branch the value is an ISendResultWebhook.

Code Samples

Parse and branch on the event type

In this sample rawBody stands for the body text of the request, however your server hands it over.

import { parseWebhook, isInboundSMSWebhook } from 'bestsms';

const webhook = parseWebhook(rawBody);

if (isInboundSMSWebhook(webhook)) {
    // Type is "SMSReply" (matched an outbound MessageID) or "SMSInbound"
    console.log(`${webhook.Type} from ${webhook.Destination}: ${webhook.Message}`);
} else {
    // Type is "SMS": the outcome for one destination
    console.log(`${webhook.MessageID} to ${webhook.Destination}: ${webhook.Status} (${webhook.Result})`);
}

Node http receiver

A full receiver written against the standard-library http module. It insists on a POST, checks the Authorization header, limits the body size, and then lets parseWebhook(...) sort send results from inbound texts. Put the secret you configured for the webhook in the BESTSMS_WEBHOOK_SECRET environment variable. It is compared with the whole raw header, so include any scheme prefix, such as Bearer , that BestSMS sends. Both values are hashed first, so the comparison takes the same time whatever the header contains and timingSafeEqual never sees two buffers of different lengths.

import { createServer } from 'http';
import { createHash, timingSafeEqual } from 'crypto';
import { parseWebhook, isInboundSMSWebhook } from 'bestsms';

const expectedAuth = process.env.BESTSMS_WEBHOOK_SECRET ?? '';
const MAX_BODY_BYTES = 64 * 1024; // webhook payloads are small

const digest = (value: string) => createHash('sha256').update(value).digest();

const isAuthorized = (header: string | undefined): boolean =>
    expectedAuth !== '' && header !== undefined && timingSafeEqual(digest(header), digest(expectedAuth));

const server = createServer((req, res) => {
    if (req.method !== 'POST') {
        res.writeHead(405).end();
        return;
    }
    if (!isAuthorized(req.headers['authorization'])) {
        res.writeHead(401).end();
        return;
    }

    let rawBody = '';
    let tooLarge = false;
    req.setEncoding('utf8');
    req.on('data', (chunk: string) => {
        if (tooLarge) return;
        rawBody += chunk;
        if (Buffer.byteLength(rawBody) > MAX_BODY_BYTES) {
            tooLarge = true;
            res.writeHead(413, { Connection: 'close' });
            res.end(() => req.destroy());
        }
    });
    req.on('end', () => {
        if (tooLarge) return;
        try {
            const webhook = parseWebhook(rawBody);

            if (isInboundSMSWebhook(webhook)) {
                console.log(`${webhook.Type} from ${webhook.Destination}: ${webhook.Message}`);
            } else {
                console.log(`${webhook.MessageID} is now ${webhook.Status} (${webhook.Result})`);
            }

            res.writeHead(200).end();
        } catch (err) {
            console.error('Rejected webhook:', (err as Error).message);
            res.writeHead(400).end();
        }
    });
});

server.listen(3000);

Reply 200 as soon as you have accepted an event. BestSMS treats any other answer as a rejection and tries the same webhook again every 5 minutes for 24 hours, so hand slow work to a queue instead of holding the response open.

Express receiver

The SDK has no dependency on a web framework, and any one that gives you the decoded JSON body will do. This Express route passes req.body straight to parseWebhook(...), which accepts objects as well as strings. The isAuthorized helper is the one from the Node sample above.

import express from 'express';
import { createHash, timingSafeEqual } from 'crypto';
import { parseWebhook, isInboundSMSWebhook } from 'bestsms';

const expectedAuth = process.env.BESTSMS_WEBHOOK_SECRET ?? '';
const digest = (value: string) => createHash('sha256').update(value).digest();
const isAuthorized = (header: string | undefined): boolean =>
    expectedAuth !== '' && header !== undefined && timingSafeEqual(digest(header), digest(expectedAuth));

const app = express();

app.post('/webhooks/bestsms', express.json({ limit: '64kb' }), (req, res) => {
    if (!isAuthorized(req.headers.authorization)) {
        res.sendStatus(401);
        return;
    }

    try {
        const webhook = parseWebhook(req.body);

        if (isInboundSMSWebhook(webhook)) {
            console.log(`${webhook.Destination} replied: ${webhook.Message}`);
        } else {
            console.log(`Message ${webhook.MessageID} status: ${webhook.Status}`);
        }
        res.sendStatus(200);
    } catch (err) {
        console.error('Rejected webhook:', (err as Error).message);
        res.sendStatus(400);
    }
});

app.listen(3000);

Note on Price: the typings declare it as string | null, matching the API specification (a text of up to 10 characters, null when there is no cost). Yet a TypeScript type is erased at runtime and parseWebhook(...) neither validates nor converts values. If a callback carries Price as a JSON number (the specification's own example is written as the bare number 0.10), your code receives a number whatever the declaration says. Code that assumes a string, such as calling .trim() on it, could therefore throw. When you need one consistent type, convert it yourself, for example webhook.Price == null ? null : Number(webhook.Price).

Addressbook

The Addressbook lets you keep recipient data in the one place, so your integration stays small and your SMS messages can be personalised. You can look after contacts, groups and the memberships between them, mirror records from a CRM, payroll system or spreadsheet, and reach a whole list of people through a single GroupID. Sending by ContactID or GroupID also shrinks each request, because the full recipient details don't have to travel with every send. Those same IDs work as the ContactID and GroupID values on a destination in SMS. A contact's FirstName, Company and Custom1–Custom4 values can be placed in a message body as merge tags such as [[FirstName]]. Whatever you create through this library also shows up in the BestSMS Dashboard. A group holds no custom fields of its own, so any merge values are read from each member contact.

client.Addressbook has four members: .Contact, .Group, .ContactGroup and .GroupContact. The layout is flat. Membership is handled by ContactGroup and GroupContact, which sit next to the other two on client.Addressbook, so there is no chaining from Contact or Group into them. Each member is created once with the client and reused. Every method takes a single options object (which you may omit when none of its fields are required) and gives back a Promise, and the same method may be called repeatedly, even concurrently, because each call copies its options before anything is sent.

A method resolves to either a success object (such as ContactApiResponseDTO) or an ErrorResponseDTO. Problems found before sending, such as a missing ID or a malformed email address, do not throw. They come back as an ErrorResponseDTO with Result set to "Error" and no HTTP request is made. The same applies to network failures and replies the library cannot parse. Narrow the result with instanceof ErrorResponseDTO; Getting Started covers the wider picture. The one deliberate exception is the BestSMS constructor, which throws when no Auth Token can be found.

Contact

Fields

FieldTypeDescription
ExTypestringA category label supplied by an outside system, handy for grouping contacts that came from your CRM. Up to 20 characters.
ExIDstringThe contact's own identifier in that outside system, so you can match the two records later. Up to 100 characters.
ViewBystringControls who can see the contact in the Dashboard: "Account", "SubAccount", "Department" or "No".
EditBystringControls who can change the contact in the Dashboard. Takes the same four values as ViewBy.
AccessControlstringEither "Limited" or "Granted"; "Granted" lets administrators view and edit the contact even when ViewBy is "No".
AttentionstringThe contact's name as it should appear when messages are personalised, and in reporting.
TitlestringA courtesy title. The API accepts an empty string, "Mr", "Mrs", "Ms", "Miss", "Dr", "Prof" or "Hon".
CompanystringThe organisation the contact belongs to. Usable as the [[Company]] merge tag.
RecipDepartmentstringThe contact's department inside their own organisation. It is unrelated to the Department code on your BestSMS account.
FirstNamestringUsable as the [[FirstName]] merge tag.
LastNamestringThe contact's family name.
PositionstringThe contact's role or job title.
StreetAddress / Suburb / City / State / Country / PostcodestringThe individual parts of a mailing address.
MainPhonestringThe contact's primary phone number.
AltPhone1–AltPhone8stringSpace for up to eight more phone numbers.
MobilePhonestringThe mobile number. When you send an SMS by ContactID, this is the number that receives it.
FaxNumberstringThe contact's fax number.
EmailAddressstringThe contact's email address. The library checks its format before sending. A malformed value is caught by Create and by Update alike, each returning an ErrorResponseDTO with Result "Error" and the message Invalid email address format for EmailAddress property.
WebAddressstringA website address.
Custom1–Custom4stringFree-form values for the merge tags [[Custom1]] to [[Custom4]].
NotesstringFree-form notes, up to 1000 characters. Notes can't be used as a merge tag.

None of these fields is mandatory, and each one is typed as a plain string. There is no enum or union type for ViewBy, EditBy, AccessControl or Title, and the library does not check them against the lists above, so the server decides whether a value is acceptable. The options object also accepts a Timezone string, but the API specification only returns Timezone on a contact and does not list it as something you can send, so leave it out. This library has no DirectPhone field: the phone fields stop at MainPhone and AltPhone1–AltPhone8.

Create

Pass the fields in one object. All of them are optional, so a contact can be created with only a name or only a number. The stored record comes back under .Contact, and it is typed as optional, which is why the samples read it with ?..

import { BestSMS, ErrorResponseDTO } from 'bestsms';

const client = new BestSMS({ AuthToken: process.env.BESTSMS_AUTH_TOKEN });

const created = await client.Addressbook.Contact.Create({
    Attention: 'Jane Citizen',
    FirstName: 'Jane',
    LastName: 'Citizen',
    MobilePhone: '+61491570006',
    EmailAddress: '[email protected]',
    MainPhone: '+61291570011',
    Company: 'Harbour Plumbing Pty Ltd',
});

if (created instanceof ErrorResponseDTO) {
    console.error(created.Result, created.ErrorMessage.join('; '));
} else {
    console.log(`Created ContactID=${created.Contact?.ContactID}`);
}

Detail

Fetch one contact by ContactID. The ID goes inside an object, and a bare string is not accepted. If the ID is missing or empty, the result is an ErrorResponseDTO with the message Missing ContactID and nothing is sent. A successful Detail result also carries Groups, a list of the GroupID values the contact belongs to.

import { ErrorResponseDTO } from 'bestsms';

const details = await client.Addressbook.Contact.Detail({ ContactID: contactID });

if (!(details instanceof ErrorResponseDTO)) {
    const contact = details.Contact;
    console.log(`${contact?.FirstName} ${contact?.LastName}, ${contact?.EmailAddress}`);
    console.log(`Member of ${details.Groups?.length ?? 0} group(s)`);
}

Update and Delete

Both calls need the ContactID in the options object. A previous result can't stand in for the ID, so read it from result.Contact?.ContactID first. Update sends only the fields you include and leaves the rest of the record alone, and the ContactID is used in the URL and is not repeated in the request body. Delete returns the details of the contact it removed.

import { ErrorResponseDTO } from 'bestsms';

const updated = await client.Addressbook.Contact.Update({
    ContactID: contactID,
    Position: 'Operations Manager',
    Custom1: 'Gold plan',
});

const removed = await client.Addressbook.Contact.Delete({ ContactID: contactID });

if (removed instanceof ErrorResponseDTO) {
    console.error(removed.Result, removed.ErrorMessage);
}

Search and List

Search filters on any combination of EmailAddress (matched exactly) and MobilePhone, MainPhone, Attention, FirstName, LastName and Company (matched partially). List returns every contact you hold, one page at a time. Both calls start from RecordsPerPage: 100 and Page: 1 when you leave those out, and each fetches just the page you ask for. Nothing is walked automatically, so loop over PageCount yourself when you need the lot.

Page size: the API specification allows RecordsPerPage from 5 to 100 for contact and group lists, and the default of 100 is the top of that range. The library does not enforce the range for Addressbook calls, so a value outside it is a matter for the server to reject.

import { ErrorResponseDTO } from 'bestsms';

const found = await client.Addressbook.Contact.Search({
    FirstName: 'Alice',
    Company: 'Harbour Plumbing',
    RecordsPerPage: 50,
    Page: 1,
});

if (!(found instanceof ErrorResponseDTO)) {
    for (const contact of found.Contacts) {
        console.log(`${contact.ContactID}: ${contact.FirstName} ${contact.LastName}`);
    }
}

// Walk every page of the full contact list
let pageNumber = 1;
let pageCount = 1;

do {
    const page = await client.Addressbook.Contact.List({ RecordsPerPage: 100, Page: pageNumber });

    if (page instanceof ErrorResponseDTO) {
        console.error(page.Result, page.ErrorMessage);
        break;
    }

    for (const contact of page.Contacts) {
        console.log(`${contact.ContactID}: ${contact.FirstName} ${contact.LastName}`);
    }

    pageCount = page.PageCount ?? 1;
    pageNumber++;
} while (pageNumber <= pageCount);

Group

Fields

FieldTypeDescription
GroupNamestringThe group's display name, up to 100 characters. Required on Create.
SubAccountstringSub-account the group is filed under.
DepartmentstringDepartment the group is filed under.
ViewEditBystringControls who can see and change the group in the Dashboard: "Account", "SubAccount", "Department" or "No". Groups use one combined setting where a contact has separate ViewBy and EditBy. Before sending, the library compares it to those four values ignoring letter case, in Create and in Update, and anything else resolves to Result "Error" with the message Invalid ViewEditBy option - must be Account/Subaccount/Department/No.
AccessControlstringTakes "Limited" or "Granted", though the library does not verify it.

Create

Groups follow the same single-object style as contacts. If GroupName is empty, the result is an ErrorResponseDTO with the message Missing GroupName. You can't choose the GroupID or the GroupCode, because the server generates both and returns them on .Group.

import { ErrorResponseDTO } from 'bestsms';

const group = await client.Addressbook.Group.Create({
    GroupName: 'Melbourne Customers',
    SubAccount: 'SALES',
    ViewEditBy: 'SubAccount',
});

if (!(group instanceof ErrorResponseDTO)) {
    console.log(`Created GroupID=${group.Group?.GroupID}, GroupCode=${group.Group?.GroupCode}`);
}

Detail, Update, Delete, and List

These work as they do for contacts: fetch one group, rename it, remove it, or step through all of them. Detail, Update and Delete need a GroupID in the options object, and a missing one resolves to Missing GroupID or GroupCode. The library will also take a GroupCode in its place and put it in the URL, but the API specification only defines paths by GroupID, so use the ID. List starts from RecordsPerPage: 100 and Page: 1.

import { ErrorResponseDTO } from 'bestsms';

const details = await client.Addressbook.Group.Detail({ GroupID: groupID });

await client.Addressbook.Group.Update({ GroupID: groupID, GroupName: 'Sydney Customers' });

await client.Addressbook.Group.Delete({ GroupID: groupID });

const groups = await client.Addressbook.Group.List({ RecordsPerPage: 100, Page: 1 });

if (!(groups instanceof ErrorResponseDTO)) {
    for (const g of groups.Groups ?? []) {
        console.log(`${g.GroupID}: ${g.GroupName}`);
    }
}

Contact ↔ Group relationships

ContactGroup approaches membership from the contact's side and GroupContact from the group's side, and both read and change the same underlying data. Each has four methods: List, Create, Delete and Detail. They are named Create and Delete, not Add and Remove, and every one takes an options object. ContactGroup.Create and GroupContact.Create send the same request, and so do the two Delete methods, so choose whichever reads better where you call it.

For Create, Delete and Detail you can give either the ID strings or the model objects from an earlier result. Passing Contact: result.Contact and Group: result.Group works because the library copies the ID out of each object for you. GroupContact.List will unwrap a Group object too. ContactGroup.List is the exception. Its options type has a Contact field, but the code only reads ContactID, so a call with just a Contact object compiles and then resolves to Missing ContactID. Always pass the ID string to that one method.

Note: Detail on both classes is a dedicated request for the one contact–group pair. It does not fetch a list page and search through it, and it takes no paging options. That endpoint, GET /addressbook/contact/{ContactID}/group/{GroupID}, is missing from the published API specification, which defines only DELETE for that path, and the library has not been run against a live BestSMS server. If you need to know whether a contact is in a group, ContactGroup.List or GroupContact.List follows the documented endpoints. In the same way, GroupContact.Create, Delete and Detail are not separately described in the specification; they reuse the contact-side paths above.

On paging, the specification gives the two relation lists a Pagination block in their replies but declares no page or recordsPerPage query parameters for them. The library sends both anyway, using the usual defaults of 100 and 1, so you can pass RecordsPerPage and Page on ContactGroup.List and GroupContact.List. Whether the server honours them is unverified.

import { ErrorResponseDTO } from 'bestsms';

// The groups a contact belongs to
const memberships = await client.Addressbook.ContactGroup.List({ ContactID: contactID });

if (!(memberships instanceof ErrorResponseDTO)) {
    for (const g of memberships.Groups ?? []) {
        console.log(`${g.GroupID}: ${g.GroupName}`);
    }
}

// Put a contact into a group, working from the contact
const added = await client.Addressbook.ContactGroup.Create({ ContactID: contactID, GroupID: groupID });

if (!(added instanceof ErrorResponseDTO)) {
    console.log(`Added to group: ${added.Group?.GroupName}`);
}

// Check one contact-group pair (not in the published specification, see the note above)
const relation = await client.Addressbook.ContactGroup.Detail({ ContactID: contactID, GroupID: groupID });

// Take a contact out of a group
await client.Addressbook.ContactGroup.Delete({ ContactID: contactID, GroupID: groupID });

// The contacts that sit in a group
const members = await client.Addressbook.GroupContact.List({ GroupID: groupID, RecordsPerPage: 100, Page: 1 });

if (!(members instanceof ErrorResponseDTO)) {
    for (const contact of members.Contacts ?? []) {
        console.log(`${contact.FirstName} ${contact.LastName}`);
    }
}

// Put a contact into a group, working from the group (same request as ContactGroup.Create)
const groupAdded = await client.Addressbook.GroupContact.Create({ GroupID: groupID, ContactID: contactID });

if (!(groupAdded instanceof ErrorResponseDTO)) {
    const contact = groupAdded.Contact;
    console.log(`Added ${contact?.FirstName} ${contact?.LastName} to the group`);
}

// Take a contact out of a group, working from the group
await client.Addressbook.GroupContact.Delete({ GroupID: groupID, ContactID: contactID });

// Check one group-contact pair
const groupRelation = await client.Addressbook.GroupContact.Detail({ GroupID: groupID, ContactID: contactID });

// Model objects from earlier results can stand in for the IDs
// (details comes from Contact.Detail and group from Group.Create above)
if (!(details instanceof ErrorResponseDTO) && !(group instanceof ErrorResponseDTO)) {
    await client.Addressbook.ContactGroup.Create({
        Contact: details.Contact,
        Group: group.Group,
    });
}

Response

A success object always has Result equal to "Success". An ErrorResponseDTO has a Result of "Error" (caught by the library before sending, or a network or parsing failure), "Failed" or "Unauthorized" (reported by the API), or another status string the API returns, such as "RecordNotFound" for a 404. Its ErrorMessage is always a string[], even when the API sent a single string. See Getting Started. List results also carry TotalRecords, RecordsPerPage, PageCount and Page (all number) for paging. In the API's JSON body the contact and group fields are not nested. The library gathers them into a typed .Contact or .Group object, and that is the property to read.

Contact.Create(...)/Detail(...)/Update(...)/Delete(...) response

FieldTypeDescription
Resultstring"Success" on this type. See above.
ContactContactModelThe contact record. It is typed as optional, so read it with ?..
Contact.ContactIDstringIdentifier of the contact.
Contact.OwnerstringThe BestSMS user the contact belongs to.
Contact.CreatedTimeLocal / Contact.CreatedTimeUTC / Contact.CreatedTimeUTC_RFC3339stringCreation time of the contact, given as local time, UTC and RFC3339 UTC.
Contact.UpdatedTimeLocal / Contact.UpdatedTimeUTC / Contact.UpdatedTimeUTC_RFC3339stringTime of the contact's latest change, in the same three forms.
Contact.TimezonestringThe timezone behind the local timestamps above.
every Contact field abovestringEach stored value comes back on Contact, for instance Contact.FirstName, Contact.EmailAddress and Contact.Custom1; the Fields table lists them all.
Groupsstring[]The GroupID of each group the contact is in. Returned by Detail(...) only.

Contact.Search(...)/List(...) response

FieldTypeDescription
Resultstring"Success" on this type.
TotalRecords / RecordsPerPage / PageCount / PagenumberPaging details for the page returned.
ContactsContactModel[]The contacts on this page. It is an empty array when nothing matched, and each entry has the same fields as Contact in the Detail result.

Group.Create(...)/Detail(...)/Update(...)/Delete(...) response

FieldTypeDescription
Resultstring"Success" on this type.
GroupGroupModelThe group record. It is typed as optional.
Group.GroupIDstringIdentifier of the group.
Group.GroupCodestringA lookup code the server generates. It is read-only, and the Fields table above has nothing you could set it with.
Group.GroupName / Group.SubAccount / Group.Department / Group.ViewEditBy / Group.AccessControlstringCome back holding whatever is stored; the Fields table lists their meaning.
Group.OwnerstringThe BestSMS user the group belongs to.
Group.CreatedTimeLocal / Group.CreatedTimeUTC / Group.CreatedTimeUTC_RFC3339stringCreation time of the group. Contact has Updated* timestamps and Group does not.
Group.TimezonestringThe timezone behind the local timestamp above.

Group.List(...) response

FieldTypeDescription
Resultstring"Success" on this type.
TotalRecords / RecordsPerPage / PageCount / PagenumberPaging details for the page returned.
GroupsGroupModel[]The groups on this page, each with the fields of Group in the Detail result. It is typed as optional.

ContactGroup.List(...) response

FieldTypeDescription
Resultstring"Success" on this type.
TotalRecords / RecordsPerPage / PageCount / PagenumberPaging details for the page returned.
ContactContactModelThe contact that the memberships belong to.
GroupsGroupModel[]The groups the contact is a member of. Unlike Groups on a contact's Detail result, these are full group records and not bare IDs.

GroupContact.List(...) response

FieldTypeDescription
Resultstring"Success" on this type.
TotalRecords / RecordsPerPage / PageCount / PagenumberPaging details for the page returned.
GroupGroupModelThe group that the members belong to.
ContactsContactModel[]The contacts that belong to the group.

ContactGroup.Create(...)/Delete(...)/Detail(...) response

FieldTypeDescription
Resultstring"Success" on this type.
ContactContactModelThe contact end of the relation.
GroupGroupModelThe group end of the relation.

GroupContact.Create(...)/Delete(...)/Detail(...) response

FieldTypeDescription
Resultstring"Success" on this type.
GroupGroupModelThe group end of the relation.
ContactContactModelThe contact end of the relation.

These last two results have no paging fields, which sets them apart from the List results above. Each describes a single contact–group pairing.

OptOut

OptOut is the part of the library that keeps your suppression list, meaning the people who have told you to stop messaging them, which supports your obligations under Australian spam rules. An entry applies to one message type (DestType) and can optionally be limited to a single SubAccount and Department. You reach it straight from client.OptOut, because this library has no Configuration object in between.

Sending to an opted-out destination is not rejected. The API still accepts the request, but it holds back delivery and reports the result as "Destination is blacklisted", so you end up with an audit trail instead of an error. To export the whole suppression list for an audit or to sync it with your own CRM, use List, described below.

DestType is an ordinary string. There is no enum or union type for it. The only message type the API supports is "SMS". The library checks just that the value is not empty, and a missing one resolves to an ErrorResponseDTO with Result "Error" and the message Missing DestType. Any other value is passed through for the server to refuse, and the API specification shows that refusal as a 400 reply with Result "Failed" and the message Unsupported DestType - Supported DestType: SMS.. Every method resolves to a success object or an ErrorResponseDTO and none of them throw for validation problems, as Getting Started explains.

Fields

FieldTypeDescription
DestTypestringRequired. The kind of message the entry covers, "SMS".
DestinationstringThe number to block, for example "+61491570006". Give either Destination or ContactID, not both. Up to 60 characters.
ContactIDstringOpts out an addressbook contact in place of a raw destination. Give either Destination or ContactID, not both.
SubAccountstringRestricts the entry to one sub-account. Leave it out to cover every sub-account.
DepartmentstringRestricts the entry to one department. Leave it out to cover every department.
StopMessagestringThe opt-out wording that was picked up, such as "Please remove me from your list". Up to 1000 characters.
NotesstringFree-form notes, up to 1000 characters.

Code samples

Create a single OptOut entry

Use this to block later sends to one destination. DestType is required, and so is exactly one of Destination or ContactID. Create checks all of this before sending, and the messages it can resolve to are Missing DestType, Missing Destination or ContactID and Use either Destination or ContactID, not both.

import { BestSMS, ErrorResponseDTO } from 'bestsms';

const client = new BestSMS({ AuthToken: process.env.BESTSMS_AUTH_TOKEN });

const created = await client.OptOut.Create({
    DestType: 'SMS',
    Destination: '+61491570006',
    Notes: 'Asked to be removed by phone',
});

if (created instanceof ErrorResponseDTO) {
    console.error(created.Result, created.ErrorMessage.join('; '));
} else {
    console.log(`Created OptOut ID=${created.ID}`);
}

Opt out an addressbook contact

A contact from the Addressbook can be blocked by its ContactID, with no raw destination needed. Supply ContactID or Destination, never both.

const contactOptOut = await client.OptOut.Create({
    DestType: 'SMS',
    ContactID: contactID,
    SubAccount: 'SALES',
});

Opt out several destinations at once

The method is called Batch. Its options are DestType, Destination, Destinations (string[]), ContactID, ContactIDs (string[]), SubAccount and Department. DestType is required, along with at least one of Destination, Destinations, ContactID or ContactIDs, otherwise the result is Missing Destination, Destinations, ContactID or ContactIDs. One request may mix those four, and unlike Create it does not stop you from combining destinations with contacts. SubAccount and Department narrow the scope just as they do for a single entry. Batch has no StopMessage or Notes option.

import { ErrorResponseDTO } from 'bestsms';

const batch = await client.OptOut.Batch({
    DestType: 'SMS',
    Destinations: ['+61491570006', '+61491570008'],
    ContactIDs: [contactID],
    Department: 'Support',
});

if (!(batch instanceof ErrorResponseDTO)) {
    console.log(`Batch opt-out created: ID=${batch.ID}`);
}

Note: Batch resolves to a single result with one ID, shaped like the results of Create and Detail. It is not an array with an item for each destination you submitted. To see the individual entries a batch produced, query them with List afterwards, filtering on TimePeriod, DestType or ContactID. List has no way to filter by destination, so match the entries against the numbers you submitted in your own code.

Update

Update changes an existing entry. It needs the OptOutID in the options object, because a response object can't be passed in its place, and the message Missing OptOutID comes back if it is empty. The OptOutID is put in the URL, and the remaining fields you pass form the request body, so anything you leave out is not sent. Unlike Create, Update makes no demands on DestType or the destination fields in the library. Be aware that the API specification reuses one schema for create and update and lists DestType as required in it. If the server turns down an update that has no DestType, include it. This behaviour has not been checked against a live server.

const updated = await client.OptOut.Update({
    OptOutID: optOutID,
    DestType: 'SMS',
    Notes: 'Verified during a second call',
});

Detail, Delete, and List

Use these to read one entry, remove an entry, or step through the suppression list. Detail and Delete each take an object holding the OptOutID (a string value, not a bare argument) and resolve to Missing OptOutID when it is empty. Delete returns the details of the entry it removed. The fetch method is spelled Detail here and on every Addressbook class.

import { ErrorResponseDTO } from 'bestsms';

const detail = await client.OptOut.Detail({ OptOutID: optOutID });

const removed = await client.OptOut.Delete({ OptOutID: optOutID });

const list = await client.OptOut.List({ DestType: 'SMS', TimePeriod: 30 });

if (!(list instanceof ErrorResponseDTO)) {
    for (const entry of list.OptOuts) {
        console.log(`${entry.Destination}, ${entry.DestType}`);
    }
}

List accepts DestType, TimePeriod (a whole number of days, so entries created in the last x days), ContactID, RecordsPerPage and Page, and every one of them is optional. RecordsPerPage starts at 100 and Page at 1. Only the page you request is returned; the library never fetches the remaining pages for you, so use PageCount to loop when you need all of them. The API documents a poll rate of no more than one call per second for this endpoint.

Page size: the API specification allows RecordsPerPage from 5 to 1000 for OptOut, a wider range than the 5 to 100 that applies to the Addressbook lists. The library does not check the bounds, so a value outside that range is refused by the server and not by the library.

Response

A success object has Result equal to "Success". Anything else arrives as an ErrorResponseDTO with Result set to "Error", "Failed", "Unauthorized" or another status string from the API such as "RecordNotFound", plus an ErrorMessage that is always a string[]. Narrow with instanceof ErrorResponseDTO before reading other fields; Getting Started has more.

Create(...)/Detail(...)/Update(...)/Delete(...)/Batch(...) response

FieldTypeDescription
Resultstring"Success" on this type. See above.
IDstringIdentifier of the entry. The field is called plain ID and not OptOutID. Pass this value as OptOutID to Detail, Update and Delete.
DestType / Destination / ContactID / SubAccount / Department / StopMessage / NotesstringCome back holding whatever is stored; the Fields table lists their meaning.
OriginalMessagestringThe incoming text that caused the opt-out. It is populated only for entries the platform raised itself after a reply like STOP; entries made through this API leave it empty.
CreatedTimeLocal / CreatedTimeUTC / CreatedTimeUTC_RFC3339stringCreation time of the entry, given as local time, UTC and RFC3339 UTC.
UpdatedTimeLocal / UpdatedTimeUTC / UpdatedTimeUTC_RFC3339stringTime of the entry's latest change, in the same three forms.
TimezonestringThe timezone behind the local timestamps above.

The fields sit directly on the result, with no nested object, which differs from how Addressbook results wrap a Contact or Group. The result of Batch(...) has this same shape; see the note above. It is not a list of the destinations you submitted.

List(...) response

FieldTypeDescription
Resultstring"Success" on this type.
TotalRecords / RecordsPerPage / PageCount / PagenumberPaging details for the page returned.
OptOutsOptOutApiResponseDTO[]The entries on this page, each carrying the fields of the Detail(...) result above. The array is empty when there are no entries.