Metadata-Version: 2.4
Name: bestsms
Version: 3.0.0.0
Summary: BestSMS REST API Helper Library for Python
Home-page: https://bestsms.com.au
Author: BestSMS Australia
Author-email: info@bestsms.com.au
License: MIT
Keywords: bestsms,api,sms
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Communications :: Telephony
Classifier: Operating System :: OS Independent
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests
Provides-Extra: test
Requires-Dist: pytest; extra == "test"
Requires-Dist: responses; extra == "test"
Requires-Dist: python-dotenv; extra == "test"
Dynamic: author
Dynamic: author-email
Dynamic: classifier
Dynamic: description
Dynamic: description-content-type
Dynamic: home-page
Dynamic: keywords
Dynamic: license
Dynamic: license-file
Dynamic: provides-extra
Dynamic: requires-dist
Dynamic: summary

# bestsms

`bestsms` is the official Python helper library for the [BestSMS](https://bestsms.com.au) REST API — send SMS messages, manage opt-outs, retrieve delivery reports, amend in-flight jobs, and manage your address book, all through a small, consistent Python surface over the v3.00 JSON API. The older v2.04 API of `bestsms` 2.4.x is still available as `bestsms.legacy` (see [Legacy API Support](#legacy-api-support)).

## Documentation

The documentation for the BestSMS API can be found [here][apidocs].
## Versions

`bestsms` uses a modified version of [Semantic Versioning](https://semver.org) for all changes. Pin at least the major version to avoid pulling in breaking changes.

### Supported Python Versions

This library supports the following Python implementations:

* Python 3.9
* Python 3.10
* Python 3.11
* Python 3.12
* Python 3.13
* Python 3.14

## Installation

Install from PyPi using [pip](http://www.pip-installer.org/en/latest/), a
package manager for Python.

    pip install bestsms

Don't have pip installed? Try installing it, by running this from the command
line:

    $ curl https://raw.github.com/pypa/pip/master/contrib/get-pip.py | python

    python setup.py install

You may need to run the above commands with `sudo`.

## Configuration

Every v3.00 facade (`Messaging`, `Reports`, `Actions`, `Addressbook`, `OptOut`) authenticates with a Bearer `AuthToken` — pass it directly to the `BestSMS` constructor, or leave it out and set it via environment variable instead:

```python
from bestsms import BestSMS

client = BestSMS(
    AuthToken="[Your Auth Token]"
)
```

| Environment variable | Purpose |
|---|---|
| `BESTSMS_AUTH_TOKEN` | Used when `AuthToken` isn't passed to the `BestSMS` constructor. |
| `BESTSMS_API_URL` | Overrides the default API base URL (`https://api.bestsms.com.au/api/v3.00`) — useful for pointing at a local/staging server. |
| `BESTSMS_ALLOW_INSECURE_HTTP` | Set to `true` to allow a non-HTTPS `BESTSMS_API_URL` (local dev only). By default, any base URL that isn't `https://` is rejected so the Bearer token is never sent over plain HTTP. |

## Quick Start

```python
from bestsms import BestSMS

client = BestSMS(
    AuthToken="[Your Auth Token]"
)

response = client.Messaging.SMS.SendMessage(
    Message="Test SMS Message click [[Reply]] to opt out",
    Destination="+61491570006",
    Reference="Test"
)

if response.Result == "Success":
    print(response.MessageID)
else:
    print(response.ErrorMessage)
```

## Messaging

> **Parameter naming:** every request-class method (`Status`, `Reschedule`, `Received`, `List`, etc.) takes PascalCase keyword arguments (`MessageID=`, `RecordsPerPage=`, ...), matching the field names used throughout this SDK's DTOs. The previous snake_case names (`message_id=`, `records_per_page=`, ...) still work and will keep working - they're translated internally with a `DeprecationWarning` - but new code should use the PascalCase form.

`SMS` supports two equivalent calling styles:

- **Flat kwargs** — pass every field to `SendMessage(...)` in one call.
- **Builder** — chain `Set(...)`/`AddDestination(...)`/`AddAttachment(...)` calls and finish with `SendMessage()` (no arguments). `Build()` returns the underlying request DTO without sending, if you want to inspect or reuse it.

### SMS

```python
# Flat kwargs
response = client.Messaging.SMS.SendMessage(
    Message="Test SMS Message click [[Reply]] to opt out",
    Destinations=[{"ToNumber": "+61491570006"}],
    Reference="Test"
)

# Builder
response = (
    client.Messaging.SMS
    .Set(
        Message="Test SMS Message click [[Reply]] to opt out",
        Reference="Test"
    )
    .AddDestination("+61491570006")
    .SendMessage()
)
```

Poll for replies to a sent message:

```python
replies = client.Messaging.SMS.Reply(response.MessageID)

if replies.Result == "Success":
    for recipient in replies.Recipients:
        for reply in recipient.SMSReplies:
            print(f"{recipient.Destination} replied: {reply.MessageText}")
```

### Using Addressbook Destinations

`AddDestination(...)` also accepts a `ContactID` or `GroupID` from your Addressbook, instead of a raw phone number:

```python
response = (
    client.Messaging.SMS
    .Set(
        Message="Hi [[FirstName]]"
    )
    .AddDestination(ContactID="[Contact ID]")
    .AddDestination(GroupID="[Group ID]")
    .SendMessage()
)
```

### Destination Model reference

`AddDestination(...)`, and the `Destination`/`Destinations` fields on `SendMessage(...)`,
all accept the same three shapes: a bare string (shorthand for `ToNumber`), a plain `dict`, or a
`bestsms.core.destination.Destination` instance. All three are equivalent — pick whichever reads best at your call site:

```python
from bestsms.core.destination import Destination

sms = client.Messaging.SMS
sms.Set(Message="Hi [[FirstName]]!")
sms.AddDestination("+61491570006")                                 # bare string
sms.AddDestination({"ToNumber": "+61491570006", "FirstName": "Alice"})  # dict
sms.AddDestination(
    Destination(ToNumber="+61491570006", FirstName="Alice")        # typed
)
response = sms.SendMessage()
```

`Destination` is a plain `@dataclass` with every field defaulting to `None`:

| Field | Description |
|---|---|
| `Recipient` | Generic destination address, sent as-is. |
| `ToNumber` / `MobilePhone` | Phone number fields — interchangeable. |
| `MainPhone` | Alternative phone field, not read by SMS. |
| `EmailAddress` | Not read by SMS. |
| `FaxNumber` | Not read by SMS. |
| `ContactID` / `GroupID` / `GroupCode` | Addressbook references — see [Using Addressbook Destinations](#using-addressbook-destinations) above. |
| `Attention` / `FirstName` / `LastName` / `Company` | Personalisation tokens, e.g. `[[FirstName]]`. |
| `Custom1`–`Custom9` | Arbitrary per-recipient personalisation values, `[[Custom1]]` … `[[Custom9]]`. |

A raw `dict` with an unrecognised key (e.g. a typo) is rejected the same way a `Destination(...)`
constructor call is — `Destination(ToNumber="...", Bogus="...")` raises `TypeError` at construction time;
an unknown dict key raises `ValueError` from `AddDestination(...)`/`Set(...)`, or is returned as
`Result="Failed"` from `SendMessage(...)`. Not every field is read by SMS.

`AddDestinations([...])` adds several destinations in one call, - each item can
be any of the three shapes shown above (bare string, dict, or typed `Destination`), mixed freely in the
same list:

```python
sms.AddDestinations([
    "+61491570006",
    {"ToNumber": "+61491570007", "FirstName": "Alice"},
    Destination(ToNumber="+61491570009", FirstName="Bob"),
])
```

`AddDestination(...)` (singular) only ever adds one destination per call - it raises `TypeError` if given a
list directly, naming `AddDestinations(...)` as the replacement.

### File Attachment reference

`AddAttachment(...)`, and the `Files` field on `SMS.SendMessage(...)`, all accept
a bare file path string, a `{"Name": ..., "Data": ...}` dict, or a `bestsms.core.file_attachment.FileAttachment`
instance - pick whichever reads best at your call site:

```python
from bestsms.core.file_attachment import FileAttachment

sms = client.Messaging.SMS
sms.Set(Message="See [[File1]]")
sms.AddAttachment("path/to/doc.pdf")                                # bare file path
sms.AddAttachment({"Name": "doc.pdf", "Data": "<base64>"})          # dict
sms.AddAttachment(
    FileAttachment("path/to/doc.pdf")                            # typed
)
response = sms.AddDestination("+61491570006").SendMessage()
```

A file path is read and base64-encoded automatically. **`Data` is never filesystem-checked, under any
circumstances** - `AddAttachment(Name, Data)`'s second argument and a dict's `Data` key both always store
the value exactly as given, so passing base64 content from an external source (e.g. an HTTP request body)
is always safe there, even if that content happens to coincidentally look like a real local path. Only the
explicit `FileName=`/positional-path form of `FileAttachment` (and the bare-file-path-string shorthand
shown above) ever reads from disk.

`AddAttachments([...])` adds several attachments in one call - each item can be any shape `AddAttachment(...)`
accepts (a path string, a `{"Name": ..., "Data": ...}` dict, or a `FileAttachment` instance), e.g.
`AddAttachments(["path/to/doc.pdf", FileAttachment("path/to/other.pdf")])`.

### Send Mode reference

`SMS`'s `Mode` field accepts the typed
`bestsms.core.send_mode.SendMode` enum as an equivalent alternative to the plain string -
`Mode=SendMode.Test`/`Mode=SendMode.Live` and `Mode="Test"`/`Mode="Live"` are fully interchangeable, since
`SendMode` is a `str`-subclass `Enum` (`SendMode.Test == "Test"` is `True`) and serializes identically over
the wire:

```python
from bestsms.core.send_mode import SendMode

response = (
    client.Messaging.SMS
    .Set(
        Message="Test SMS Message click [[Reply]] to opt out",
        Mode=SendMode.Test
    )
    .AddDestination("+61491570006")
    .SendMessage()
)
```

Leaving `Mode` unset is unaffected either way - it's dropped from the outgoing request body, and the server
applies its own `"Live"` default, exactly as before.

### Webhooks

If you configure a `WebhookCallbackURL` on a send, BestSMS will `POST` a status update (and inbound replies) back to that URL. `bestsms.webhooks` gives you typed shapes to parse that payload in your own receiver — the SDK never sends these itself, it only documents them:

```python
from flask import Flask, request
from bestsms.webhooks import ResultWebhookPayload, InboundSMSWebhookPayload

app = Flask(__name__)

@app.route("/webhooks/bestsms/result", methods=["POST"])
def bestsms_result_webhook():
    payload = ResultWebhookPayload(**request.get_json())
    print(f"{payload.MessageID} is now {payload.Status}")
    return "", 204

@app.route("/webhooks/bestsms/inbound-sms", methods=["POST"])
def bestsms_inbound_sms_webhook():
    payload = InboundSMSWebhookPayload(**request.get_json())
    print(f"Reply from {payload.Destination}: {payload.Message}")
    return "", 204
```

## Reports

`client.Reports` is a thin convenience wrapper, not a distinct implementation: every method here
delegates straight to the same `Status(...)`/`Received(...)` methods reachable directly via
`client.Messaging.SMS` (e.g. `Reports.Status.Poll(...)` and `Reports.SMSReply.Poll(...)` internally call
`Messaging.SMS.Status(...)`, and `Reports.SMSReceived.Poll(...)` calls `Messaging.SMS.Received(...)`) —
so the response shape and field names are those of the SMS response (see Response Structure below), not a
Reports-specific shape.

### Get Message Status

Internally the same call as `client.Messaging.SMS.Status(MessageID)`.

```python
response = client.Reports.Status.Poll(
    MessageID="ID123456"
)

print(response)
```

### Get SMS Reply

SMS-specific: internally the same call as `client.Messaging.SMS.Status(MessageID)`. Inspect each entry in
the response's `Recipients` list for its `SMSReplies` field to see inbound replies attached to that
recipient — there's no separate reply-only response shape.

```python
response = client.Reports.SMSReply.Poll(
    MessageID="ID123456"
)

print(response)
```

### Get SMS Received List

SMS-specific: internally the same call as `client.Messaging.SMS.Received(...)`, for inbound SMS not tied
to a specific outbound `MessageID` (e.g. an unprompted message to your inbound number). Only ever returns
the requested page, so loop over `Page` yourself if you need everything.

```python
response = client.Reports.SMSReceived.Poll(
    DateFrom="2023-07-01 00:00:00",
    DateTo="2023-08-01 00:00:00"
)

print(response)
```

## Actions

Each action takes the `MessageID` of the SMS to act on, and routes to `client.Messaging.SMS`'s own
`Abort`/`Reschedule` method. Every action's response type (`Result`, `ErrorMessage`, plus action-specific
fields like `ActionResult`/`Status` on success) is the same as the SMS response (see Response Structure below).

### Abort a Job

```python
response = client.Actions.Abort.SendRequest(
    MessageID="ID123456"
)

print(response)
```

### Reschedule a Job

```python
response = client.Actions.Reschedule.SendRequest(
    MessageID="ID123456",
    SendTime="2023-07-10T09:00"
)

print(response)
```

## Opt-Out Management

Manage the do-not-contact list that BestSMS checks before sending. `DestType` accepts `SMS`.

```python
# Create a single opt-out
response = client.OptOut.Create(
    DestType="SMS",
    Destination="+61491570006",
    Notes="Requested via support call"
)

# Create a batch of opt-outs in one call
response = client.OptOut.CreateBatch(
    DestType="SMS",
    Destinations=["+61491570006", "+61491570008"]
)

# List opt-outs, optionally filtered by time period / dest type / contact
response = client.OptOut.List(
    DestType="SMS",
    RecordsPerPage=20,
    Page=1
)

# Get details of one opt-out
response = client.OptOut.Details("[Opt-Out ID]")

# Update an opt-out (partial - only supplied fields are changed)
response = client.OptOut.Update("[Opt-Out ID]", Notes="Updated notes")

# Delete an opt-out
response = client.OptOut.Delete("[Opt-Out ID]")

# Update/Delete using a Response you already have (e.g. from Details()) -
# only the fields Update() actually accepts are sent (read-only fields like
# ID/timestamps are dropped automatically), and the ID itself can come
# straight from the Response too
details_result = client.OptOut.Details("[Opt-Out ID]")
details_result.Notes = "Updated notes"
response = client.OptOut.Update(details_result, model=details_result)
response = client.OptOut.Delete(details_result)

print(response)
```

## Addressbook

### Contacts

```python
# List
response = client.Addressbook.Contact.List(RecordsPerPage=10, Page=1)

# Search
response = client.Addressbook.Contact.Search(Attention="Joe", RecordsPerPage=10, Page=1)

# Detail
response = client.Addressbook.Contact.Detail("[Contact ID]")

# Create
response = client.Addressbook.Contact.Create(
    Title="Mr",
    Company="BestSMS Australia",
    FirstName="First",
    LastName="Last",
    MobilePhone="+61491570009"
)

# Update
response = client.Addressbook.Contact.Update("[Contact ID]", Attention="Test Attention")

# Delete
response = client.Addressbook.Contact.Delete("[Contact ID]")

# Update/Delete using a Response you already have (e.g. from Detail()) -
# only the fields Update() actually accepts are sent (read-only fields like
# ContactID/Owner/timestamps are dropped automatically), and the ID itself
# can come straight from the Response too
detail_result = client.Addressbook.Contact.Detail("[Contact ID]")
detail_result.Attention = "Test Attention"
response = client.Addressbook.Contact.Update(detail_result, model=detail_result)
response = client.Addressbook.Contact.Delete(detail_result)

print(response)
```

### Groups

```python
# List
response = client.Addressbook.Group.List(RecordsPerPage=10, Page=1)

# Detail
response = client.Addressbook.Group.Detail("[Group ID]")

# Create
response = client.Addressbook.Group.Create(GroupName="[Group Name]")

# Update
response = client.Addressbook.Group.Update("[Group ID]", GroupName="[New Group Name]")

# Delete
response = client.Addressbook.Group.Delete("[Group ID]")

# Update/Delete using a Response you already have (e.g. from Detail()) -
# only the fields Update() actually accepts are sent (read-only fields like
# GroupID/Owner/timestamps are dropped automatically), and the ID itself
# can come straight from the Response too
detail_result = client.Addressbook.Group.Detail("[Group ID]")
detail_result.GroupName = "[New Group Name]"
response = client.Addressbook.Group.Update(detail_result, model=detail_result)
response = client.Addressbook.Group.Delete(detail_result)

print(response)
```

### Contact Groups

Manage which groups a contact belongs to:

```python
# List the groups a contact belongs to
response = client.Addressbook.ContactGroup.List("[Contact ID]", RecordsPerPage=10, Page=1)

# Detail of one contact/group membership
response = client.Addressbook.ContactGroup.Detail("[Contact ID]", "[Group ID]")

# Add a contact to a group
response = client.Addressbook.ContactGroup.Create("[Contact ID]", "[Group ID]")

# Remove a contact from a group
response = client.Addressbook.ContactGroup.Delete("[Contact ID]", "[Group ID]")

print(response)
```

### Group Contacts

The same relationship viewed from the group's side:

```python
# List the contacts belonging to a group
response = client.Addressbook.GroupContact.List("[Group ID]", RecordsPerPage=10, Page=1)

# Detail of one group/contact membership
response = client.Addressbook.GroupContact.Detail("[Group ID]", "[Contact ID]")

# Add a contact to a group
response = client.Addressbook.GroupContact.Create("[Group ID]", "[Contact ID]")

# Remove a contact from a group
response = client.Addressbook.GroupContact.Delete("[Group ID]", "[Contact ID]")

print(response)
```

`List()` results only cover the requested page — this SDK never auto-walks every page on your behalf. Inspect `PageCount`/`TotalRecords` on the response and request further pages yourself if you need them.

## Legacy API Support

The v2.04 API client of `bestsms` 2.4.x is still available, unchanged, as `bestsms.legacy`. Existing 2.4.x code only needs one import line changed — it can be authenticated with either `AuthToken` or the legacy `Sender`/`APIKey` pair:

```python
from bestsms.legacy import BestSMS

client = BestSMS(
    Sender="[Your Sender]",
    APIKey="[Your API Key]"
)

response = client.Messaging.SMS(
    Recipients=["+61491570006"],
    MessageText="Hi there!"
).SendMessage()

print(response)
```

The legacy client builds its URLs from `bestsms/_config.py` (`https://api.bestsms.com.au/api/v3.00`) and keeps the 2.4.x surface: `Messaging.SMS`, `Reports` (`Status`, `SMSReply`, `SMSReceived`), `Actions` (`Abort`, `Reschedule`, `Resubmit`) and `Addressbook` (`Contact`, `Group`, `ContactGroup`, `GroupContact`). `Resubmit` exists only here, not in the v3.00 client. The legacy client keeps its credentials in process-wide module globals (as 2.4.x did), so use one set of legacy credentials per process.

### Migrating from bestsms 2.4.x

- `from bestsms.legacy import BestSMS` keeps old code working unchanged.
- `from bestsms import BestSMS` is the v3.00 client: Bearer-token auth, PascalCase fields, `BESTSMS_*` environment variables, and different call shapes (`client.Messaging.SMS.SendMessage(...)` instead of `client.Messaging.SMS(...).SendMessage()`).
- The v3.00 client has no `Resubmit`.

## Response Structure

Every response object (from `.Messaging`, `.Reports`, `.Actions`, `.Addressbook` and `.OptOut`) exposes at least:

- `.Result` — `"Success"` on success, otherwise a failure code such as `"Failed"`, `"Unauthorized"` or `"RecordNotFound"`.
- `.ErrorMessage` — a list of human-readable error strings, populated whenever `.Result != "Success"`.

Successful responses additionally expose the fields relevant to that call (e.g. `.MessageID` on a send, `.ContactID` on a contact create/detail, `.OptOuts` on an opt-out list).

If you want to type-hint against a response's class (e.g. in a function signature), import it from `bestsms.models` rather than the internal `bestsms.api.v300....models.responses...` module path:

```python
from bestsms.models import SMSResponse

def handle(result: SMSResponse) -> None:
    ...
```

`bestsms.models` re-exports every response type across `.Messaging`/`.Reports`/`.Actions`/`.Addressbook`/`.OptOut` under its plain name (`SMSResponse`, `ContactResponse`, `OptOutListResponse`, etc.) — these were named with a `...DTO` suffix in earlier versions, which still works via a `DeprecationWarning`-emitting alias.

### `Recipients` are typed objects

`SMS.Status(...)`'s `Recipients` list holds typed `SMSRecipient` objects (with a nested `SMSReply` type for inbound replies), so `entry.Field` works. Dict-style access (`entry["Field"]`, `entry.get("Field")`) works too.

### Common response enums

- **`JobStatus`** (on `Status(...)`'s response): `"Pending"`, `"Delayed"`, `"Completed"`,
  `"CreditHold"`, `"Unknown"`.
- **Per-recipient `Status`** (inside `Recipients`/`Messages` entries): `"Success"`, `"Failed"`, `"Pending"`.
- **Per-recipient `Type`** (inside `Recipients` entries): the recipient type, e.g. `"SMS"`.

### ID-validation guards

Every method that takes a required, standalone ID parameter (e.g. `MessageID` on `Status(...)`,
`ContactID`/`GroupID` on `Detail(...)`/`Update(...)`/`Delete(...)`, `OptOutID` on `OptOut.Details(...)`)
raises `ValueError` immediately if you pass `None` or `""` — rather than silently building a broken
request URL like `.../contact/None` or comparing an unset ID in memory. `Update(...)`/`Delete(...)`
methods that also accept a Response object in place of a plain ID string raise the same `ValueError` if
that Response's ID field itself isn't set (e.g. it came from a failed prior call). This is a client-side
guard — no HTTP request is made when it fires.

## Error Handling

```python
result = client.Messaging.SMS.SendMessage(
    Message="Hi there!",
    Destination="+61491570006"
)

if result.Result != "Success":
    print(result.ErrorMessage)
else:
    print(result.MessageID)
```

## Getting Help

If you need help installing or using the library, please check the [BestSMS website](https://bestsms.com.au) if you don't find an answer to your question.

[apidocs]: https://bestsms.com.au
