Python client for the Sendly SMS API (REST endpoint: https://api.sendly.link).
- Zero runtime dependencies (only
urllibfrom the standard library), full type annotations,py.typed. - Python 3.10+.
- No automatic retries: a retried request could send the SMS twice, so the client never retries requests on its own. If you retry a send on the application side, make sure first that the previous attempt definitely failed before the request was accepted.
pip install sendly-clientFor development (tests):
pip install -e ".[dev]"| Setting | Constructor argument | Environment variable | Default value |
|---|---|---|---|
| API token | token |
SENDLY_TOKEN |
– (required) |
| Base URL | base_url |
SENDLY_BASE_URL |
https://api.sendly.link |
| HTTP timeout (s) | timeout |
– | 30.0 |
Arguments passed explicitly to the constructor take precedence over
environment variables. For backward compatibility, the legacy variables
ACTIO_TOKEN and ACTIO_BASE_URL are also supported: the client reads them
only when the corresponding SENDLY_* variable is not set (when both are
set, SENDLY_* wins). The token is generated in the Sendly customer panel
(or provided by customer support).
Both send_sms and send_sms_multi also accept an optional timeout
parameter (in seconds) per call, which overrides the client's default value
for that one request:
client = SendlyClient("your-api-token", timeout=30.0)
client.send_sms(from_="48732129000", to="48732129001", body="Test Sendly", timeout=5.0)Every outgoing request to the API includes:
User-Agent: sendly-python-client/<version> (Python/<major>.<minor>), e.g.sendly-python-client/2.0.0 (Python/3.14)– the version comes fromsendly.__version__.X-Request-Id: <uuid>– a fresh UUID v4 for every request, a correlation identifier for contacting customer support and clarifying suspected double-sends (a complement to the no-retry policy).
from sendly import SendlyClient
client = SendlyClient("your-api-token") # or set SENDLY_TOKEN
result = client.send_sms(from_="48732129000", to="48732129001", body="Test Sendly")
print(result.message_id) # unique message id, e.g. "a906cff7719bd889"Equivalent in curl:
curl -X POST https://api.sendly.link/api/sms \
-H "Authorization: Bearer $SENDLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"from": "48732129000", "to": "48732129001", "body": "Test Sendly"}'from is a Python keyword, so the parameter is called from_ (the JSON
field sent to the API is still from).
results = client.send_sms_multi(
from_="48732129000",
to=["48732129001", "48732129002"],
body="Test Sendly",
)
for item in results:
print(item.number, item.message_id)Equivalent in curl:
curl -X POST https://api.sendly.link/api/sms-multi \
-H "Authorization: Bearer $SENDLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"from": "48732129000", "to": ["48732129001", "48732129002"], "body": "Test Sendly"}'to must contain 1-100 unique numbers. Server-side validation is
all-or-nothing: every number must be a valid Polish mobile number, and the
numbers must be unique – otherwise the whole request is rejected. This API
method must additionally be activated for your token by Sendly customer
support.
Sendly sends incoming messages, and (once activated) delivery notifications,
as POST requests to the webhook address configured in the customer panel.
Delivery is a single attempt, redirects are not followed, and no
authentication is performed against your webhook; requests originate from
the current IP address of the api.sendly.link host.
parse_webhook accepts decoded JSON as a dict, or the raw request body as
str/bytes, and returns a typed model:
from sendly import DeliveryNotification, IncomingMessage, parse_webhook
payload = parse_webhook(request_body) # dict | str | bytes
if isinstance(payload, IncomingMessage):
# {"type": "MESSAGE", "from": ..., "to": ..., "body": ...}
print(f"SMS from {payload.from_} to {payload.to}: {payload.body}")
elif isinstance(payload, DeliveryNotification):
# {"type": "NOTIFICATION", "message_id": ..., "status": "DELIVERED" | "ERROR"}
print(f"Message {payload.message_id} is {payload.status.value}")Invalid payloads and unknown type/status values raise
SendlyValidationError. Note that IncomingMessage.from_ may be an
alphanumeric sender name (a sender ID override), not only a string of
digits.
All library errors inherit from SendlyError:
SendlyValidationError– invalid input detected client-side, before any HTTP request is sent:from/tomust be digit strings 9-11 characters long,bodymust not be empty, andtoin a multi-send must contain 1-100 unique numbers.SendlyApiError– the API responded with an error status (403an authorization problem,422a validation problem). Carriesstatus_code, a parsederrorsmap (optional keystoken,from,to,body, each a list of messages), andraw(the raw response body text – a fallback for when the response is not JSON).
Network errors (DNS, connection, timeout) propagate as standard
urllib.error.URLError / OSError.
from sendly import SendlyApiError, SendlyClient, SendlyValidationError
client = SendlyClient("your-api-token")
try:
client.send_sms(from_="48732129000", to="48732129001", body="Test Sendly")
except SendlyValidationError as exc:
print(f"Bad input, nothing was sent: {exc}")
except SendlyApiError as exc:
if exc.status_code == 403:
print("Authorization problem:", exc.errors.get("token", [exc.raw]))
elif exc.status_code == 422:
for field, messages in exc.errors.items():
print(f"{field}: {'; '.join(messages)}")
else:
print(exc)Outgoing messages are encoded as UCS2 and split every 60 characters. GSM7 encoding can optionally be enabled (customer panel / customer support), with a single-message limit of up to 160 characters.
3CX SMS API mode (/api/tcx) is mutually exclusive with the REST SMS API
and is not implemented in this client.
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/python -m pytest -qTests run against a local HTTP stub server and never query the real API.
MIT – Copyright (c) 2026 ACTIO. See LICENSE.