Unicom360 Python SDK
The official Python client for the Unicom360 API: SMS, email and WhatsApp over one JSON API.
- Python 3.9 or later, no dependencies (the standard library only).
- Typed (
py.typed), checked withmypy --strict. - Targets Unicom360 API contract v0.1.0 (
openapi.yaml0.1.0 in unicom360-openapi), and runs that contract's full conformance suite in CI. - API reference: https://unicom360.com/docs.
There is no sandbox: every accepted send is real and billed.
Contents
- Install
- Quick start
- Every operation
- Results
- Errors
- Retries
- auto_reference_ids
- Webhooks
- SMS and email delivery reports
- GSM-7 helpers
- Configuration
- Logging
- Known differences from the published docs
- Versioning
Install
pip install unicom360
Quick start
from unicom360 import Unicom360
client = Unicom360() # reads UNICOM360_API_KEY; or Unicom360(api_key="...")
result = client.sms.send(
from_="Unicom360",
to=["260971234567", "260961234567"],
message="Your verification code is 482193. It expires in 5 minutes.",
)
for recipient in result.results:
print(recipient.mobile_number, recipient.status, recipient.message_id)
from is a Python keyword, so the sender is from_ everywhere (arguments and model fields).
Every other name is the API's own.
Every operation
| Operation | Method | Returns |
|---|---|---|
| Send an SMS | client.sms.send(...) |
SmsSendResult |
| Send an email | client.email.send(...) |
EmailSendResult |
| Send a WhatsApp template | client.whatsapp.send_template(...) |
TemplateSendResult |
| Send a WhatsApp session message | client.whatsapp.send_session(message) |
SessionSendResult |
| WhatsApp message status | client.whatsapp.status(message_ids) |
StatusResult |
| WhatsApp templates | client.whatsapp.templates() |
TemplateList |
| One balance | client.balance.get(bundle_type=None) |
Balance |
| Every balance | client.balance.all() |
Balances |
| Health | client.health() |
Health |
Every operation also takes timeout= (seconds per attempt) and max_retries=, which override
the client's settings for that call.
SMS
result = client.sms.send(
from_="Unicom360", # a registered sender ID
to="260971234567", # one number, or a list
message="Your order ZM4821 is ready.", # GSM-7, at most 1,200 characters
user_reference_id="order-10421", # optional: makes the send safe to retry
callback_url="https://example.com/webhooks/unicom360/sms", # optional
source="crm", # optional label for your reporting
)
print(result.api_reference, [r.status for r in result.results])
result = client.email.send(
from_="noreply@yourdomain.com",
to=["jane@example.com", "john@example.com"],
subject="Your order is confirmed",
email_body="<p>Thanks for your order. Order ZM4821 is confirmed.</p>",
reply_to="support@yourdomain.com", # optional
callback_url="https://example.com/webhooks/unicom360/email", # optional
)
jane = result.by_address("jane@example.com")
WhatsApp template
from unicom360 import TemplateBody, TemplateButton, TemplateHeader, TemplateRecipient
result = client.whatsapp.send_template(
template="order_ready",
from_="260960123456", # optional: your WhatsApp number
language="en_US", # optional
recipients=[
TemplateRecipient(
to="260971234567",
user_reference_id="inv-42",
header=TemplateHeader.image("https://example.com/receipts/ZM-4821.png"),
body=TemplateBody.of(["Ada", "ZM-4821"]), # or TemplateBody.named({...})
buttons=[TemplateButton.url(0, "ZM-4821")], # or TemplateButton.quick_reply(...)
),
TemplateRecipient(to="260961234567", body=["Joseph", "ZM-4822"]),
],
)
print(result.accepted_count, result.rejected_count)
for rejected in result.rejected():
print(rejected.to, rejected.reason)
Headers: TemplateHeader.text(value) (or a plain string), .image(url), .video(url),
.document(url, filename=None). An AUTHENTICATION template takes the code as
TemplateRecipient(to=..., otp="482193"). A recipient may also be a dict in the API's shape.
A rejected recipient is not an error. When every recipient is rejected the call raises
NoValidRecipientsError, whose results say why.
WhatsApp session message
Inside the 24-hour customer service window, build a message with unicom360.session:
from unicom360 import session
from unicom360.session import ListRow, ListSection, ReplyButton
client.whatsapp.send_session(
session.text("260971234567", "Your order is ready.", reply_to=inbound_wamid)
)
client.whatsapp.send_session(
session.buttons(
"260971234567",
"Collect today or deliver?",
[ReplyButton("collect", "Collect"), ReplyButton("deliver", "Deliver")],
)
)
| Builder | Sends |
|---|---|
session.text(to, text) |
text |
session.image(to, url, caption=None) |
image |
session.video(to, url, caption=None) |
video |
session.document(to, url, caption=None, filename=None) |
document |
session.audio(to, url) |
audio |
session.sticker(to, url) |
sticker |
session.buttons(to, body, buttons, header=None, footer=None) |
up to 3 reply buttons, each ReplyButton(id, title) or an (id, title) pair |
session.list(to, body, button, sections, header=None, footer=None) |
a list of ListSection(rows=[ListRow(id, title, description=None)], title=None) |
session.location(to, latitude, longitude, name=None, address=None) |
a location pin |
session.contacts(to, contacts) |
contact cards in Meta's shape (dicts) |
session.reaction(to, message_id, emoji) |
a reaction; emoji="" removes yours |
Every builder takes the keyword arguments from_, user_reference_id, callback_url and (all
but reaction) reply_to. reply_to and a reaction's message_id take either a message_id
a send returned or the wamid. id of an inbound message, exactly as a message.received
webhook delivers it. Outside the window the call raises SessionWindowClosedError: send a
template instead.
WhatsApp status and templates
status = client.whatsapp.status(["7c1f3a2e-5b64-4d2f-9a8e-0f1b2c3d4e5f"]) # 1 to 100 ids
entry = status.by_id("7c1f3a2e-5b64-4d2f-9a8e-0f1b2c3d4e5f")
if entry is not None:
print(entry.status) # ACCEPTED, SENT, DELIVERED, READ, FAILED, REJECTED, NOT_FOUND
templates = client.whatsapp.templates()
order_ready = templates.by_name("order_ready", "en_US")
Balance and health
from unicom360 import BundleType
client.balance.get().balance # Decimal("1250"): the SMS bundle
client.balance.get(BundleType.WHATSAPP).balance # SMS, SMS_INTERNATIONAL, EMAIL, WHATSAPP
client.balance.all().balances # {"SMS": Decimal("1250"), ...}
client.health().status # "ok"; sends no API key
Balances are decimal.Decimal, exactly as the gateway sent them.
Runnable versions of all of these are in examples/.
Results
Every result is an immutable, typed dataclass:
- fields named as in the API (
from_forfrom),Nonewhen absent or null; raw: the JSON object it was parsed from, unmodified;extra: every field the gateway sent that this version does not model, so a field added tomorrow is readable today.
Statuses, categories and reasons are plain strings: a value this version does not know passes
through. message_id is always a string.
SMS and email results are not in request order past the tenth recipient. The SDK keeps the response's order; find a recipient with the helpers:
| Result | Helpers |
|---|---|
SmsSendResult |
accepted(), invalid(), by_number(number) |
EmailSendResult |
accepted(), invalid(), by_address(address) |
TemplateSendResult |
accepted(), rejected(), by_number(number); counts in accepted_count, rejected_count |
StatusResult |
by_id(message_id) |
TemplateList |
by_name(name, language=None) |
by_number compares digits only ("+260 97 123 4567" finds "260971234567"); it does not
expand local formats, so pass the international form. by_address ignores case and
surrounding spaces.
A partial success (an Invalid mobile number among the results, rejected template recipients)
is a result, never an exception.
Errors
Unicom360Error
+-- ConfigurationError no API key, an unusable option, a blank webhook secret
+-- APIConnectionError no complete response (.sent, .cause)
| +-- APITimeoutError
+-- APIStatusError any other non-2xx, or a 2xx that is not an envelope
| +-- AuthenticationError 401
| +-- RateLimitError 429 (.retry_after)
| +-- InvalidRequestError 400: the request's shape is wrong
| +-- APIServerError 5xx
+-- APIError HTTP 200 with success: false: a business refusal
| +-- InsufficientBundleError
| +-- DuplicateReferenceError (.original_message_id)
| +-- NoValidRecipientsError (.results)
| +-- SessionWindowClosedError
+-- SignatureVerificationError a webhook failed verification (.reason)
HTTP 200 is not success: a refusal such as an unknown sender ID arrives as HTTP 200 with
success: false and raises APIError. Every error raised by an operation has message,
http_status, api_reference (quote it to support), raw_body and attempts. APIStatusError
and APIError add code, data and headers.
from unicom360 import APIError, InsufficientBundleError, RateLimitError, codes
try:
client.sms.send(from_="Unicom360", to="260971234567", message="Hello")
except InsufficientBundleError:
... # top up
except RateLimitError as error:
print(f"retry in {error.retry_after:.0f} s")
except APIError as error:
if error.code == codes.INVALID_SENDER_ID:
...
print(error.code, error.message, error.api_reference)
code is normalised from the gateway's message (unicom360.normalize_code), so sentences and
codes compare the same way: Invalid From Sender ID and Invalid Sender are both
INVALID_SENDER_ID, duplicate user reference id and DUPLICATE_MSG_REF are both
DUPLICATE_REFERENCE, and anything unrecognised is UNKNOWN. The constants are in
unicom360.codes: INVALID_SENDER_ID, SENDER_ID_NOT_ALLOWED, INVALID_RECIPIENT_LIMIT,
INSUFFICIENT_BUNDLE, DUPLICATE_REFERENCE, INVALID_PARAMETERS, SENDER_REQUIRED,
TEMPLATE_NOT_FOUND, TEMPLATE_NOT_APPROVED, LANGUAGE_REQUIRED, TEMPLATE_SENDER_MISMATCH,
NO_VALID_RECIPIENTS, SEND_FAILED, SESSION_WINDOW_CLOSED, REPLY_TO_NOT_FOUND,
REACTION_TARGET_NOT_FOUND, INVALID_NUMBER, INVALID_USER_REFERENCE_ID, MEDIA_URL_INVALID,
CLIENT_NOT_FOUND, MISSING_API_KEY, INVALID_AUTHORIZATION, AUTHENTICATION_FAILED,
CREDENTIAL_DISABLED, CLIENT_DISABLED, CREDENTIAL_EXPIRED, IP_NOT_ALLOWED,
RATE_LIMIT_EXCEEDED and more.
A DuplicateReferenceError with attempts greater than 1 means an earlier attempt of the same
call very probably succeeded: the retry was refused because that attempt had already used the
reference. Treat it as sent, and look it up rather than sending again.
Retries
The client retries what is safe to retry, so a retry never sends a message twice:
| Outcome | Retried |
|---|---|
| HTTP 429 | always (it is answered before any processing) |
| No connection made (DNS, refused, TLS, connect timeout) | always |
A failure after the request was sent, an HTTP 5xx, or SEND_FAILED |
only if the call is retry-safe |
| HTTP 400, 401, any other 4xx, any other refusal | never |
Retry-safe calls: every read (status, templates, balance, health); an SMS or WhatsApp
session send with a user_reference_id; a template send where every recipient has one.
Email is never retry-safe, even with a reference: the gateway's duplicate check for email is
not atomic, so it is retried only after a 429 or a failure to connect.
Waits: after a 429, until the next UTC minute plus up to a second of jitter (the limit is a fixed
UTC-minute window and the gateway sends no Retry-After); otherwise full-jitter exponential
backoff (random() * min(8, 0.5 * 2^attempt) seconds). max_retries (default 2) bounds the
retries and max_retry_wait (default 60 s) any single wait; a 429 wait longer than
max_retry_wait raises at once. The raised error is the last attempt's, with attempts set.
client = Unicom360(max_retries=4)
client.balance.get(max_retries=0) # per call
auto_reference_ids
client = Unicom360(auto_reference_ids=True)
Off by default. When on, every send without a user_reference_id gets one before the first
attempt (u360- followed by 32 hex characters), reused on every retry, and each template
recipient without one gets its own. That makes SMS and WhatsApp sends retry-safe. A reference
you pass is never replaced. The generated values are in the request, in the debug log line of
the request, and in the result's raw where the API echoes them (template and session
results). For email the reference still lets the gateway refuse a later repeat of the same
send, but it does not make email retry-safe (see above).
Webhooks
WhatsApp events (message.status, message.received, webhook.test) are signed. Verify and
parse a delivery with webhooks.construct_event, passing the raw body bytes, the request
headers and your webhook secret (the 43-character string from the portal, used as it is: never
base64-decode it):
from unicom360 import SignatureVerificationError, webhooks
try:
event = webhooks.construct_event(raw_body, headers, secret) # tolerance=300 seconds
except SignatureVerificationError as error:
... # answer 401; error.reason says which check failed
if isinstance(event, webhooks.MessageStatusEvent):
print(event.data.message_id, event.data.status) # sent, delivered, read, failed
elif isinstance(event, webhooks.MessageReceivedEvent):
print(event.data.from_, event.data.type, event.data.text)
elif isinstance(event, webhooks.TestEvent):
print(event.data.message)
else: # webhooks.UnknownEvent: an event this version does not know; never raises
print(event.event, event.data)
webhooks.verify(raw_body, headers, secret, tolerance=300) only verifies. headers can be a
dict, Flask/Werkzeug, Django or Starlette headers, or a list of pairs; names are matched without
regard to case.
A secret that is None, empty or only whitespace, as str or bytes (an unset environment
variable, say), raises ConfigurationError before any other check, because anyone can forge a
signature with an empty key. That is a fault in your configuration, not a failed verification:
let it answer 500, and do not catch it where you answer 401.
Flask
@app.post("/webhooks/unicom360/whatsapp")
def whatsapp_webhook():
try:
event = webhooks.construct_event(request.get_data(), request.headers, SECRET)
except SignatureVerificationError as error:
return error.reason, 401
handle(event)
return "", 200
Django
def whatsapp_webhook(request): # route csrf_exempt(whatsapp_webhook), POST only
try:
event = webhooks.construct_event(request.body, request.headers, SECRET)
except SignatureVerificationError as error:
return HttpResponse(error.reason, status=401)
handle(event)
return HttpResponse(status=200)
FastAPI
@app.post("/webhooks/unicom360/whatsapp")
async def whatsapp_webhook(request: Request) -> Response:
body = await request.body()
try:
event = webhooks.construct_event(body, request.headers, SECRET)
except SignatureVerificationError as error:
return Response(error.reason, status_code=401)
handle(event)
return Response(status_code=200)
Full versions: examples/webhook_flask.py,
examples/webhook_django.py,
examples/webhook_fastapi.py.
Good to know:
- Deliveries are retried on any non-2xx (after 30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h and
12 h). Answer 2xx quickly and drop repeats by
event.delivery_id. verifyrejects a timestamp more thantoleranceseconds (300 by default) from your clock, in either direction.- In
message.received: a media caption arrives intext, not inmedia;payloadis set only for location, contacts ({"contacts": [...]}), reaction, sticker and unsupported messages; an interactive or button reply has the chosen title intext,payloadisNone, andreply_tois thewamid.id of your interactive message (the button or row id is not passed on).
SMS and email delivery reports
The reports posted to an SMS or email callback_url are unsigned and never retried, so
anyone can post one: treat them as hints, and confirm anything that matters another way.
report = webhooks.parse_sms_callback(raw_body) # message_id, status, mobile_number
report = webhooks.parse_email_callback(raw_body) # message_id, status, email_address, message
Both raise Unicom360Error for a body that is not a JSON object. The email report's numeric
message_id becomes the string the send returned.
GSM-7 helpers
The gateway accepts only GSM-7 text (the basic set plus ^{}\[~]|€, no form feed), at most
1,200 characters, and bills max(1, ceil(length / 160)) units per recipient (a CR LF pair
counts as one character). The SDK never checks this itself; these opt-in helpers give the
gateway's answers before you send:
from unicom360 import gsm
gsm.is_gsm7("Hello €") # True
gsm.length("Hi\r\nthere") # 8
gsm.units("a" * 161) # 2
gsm.non_gsm_characters("It’s") # [NonGsmCharacter(char='’', index=2)]
gsm.validate_sms("It’s ready") # [SmsIssue.NON_GSM]; also EMPTY and TOO_LONG
SmsIssue values compare equal to the strings "empty", "too_long" and "non_gsm".
gsm.CHARSET, gsm.MAX_LENGTH and gsm.UNIT_LENGTH are the gateway's values.
Configuration
Unicom360(
api_key=None, # default: UNICOM360_API_KEY
base_url=None, # default: UNICOM360_BASE_URL, else https://api.unicom360.com
timeout=60.0, # seconds per attempt, sending to the end of the body
connect_timeout=10.0, # seconds to connect, TLS included
max_retries=2,
max_retry_wait=60.0,
auto_reference_ids=False,
app_info=None, # AppInfo("shopsync", "2.3.0", "https://shopsync.example")
transport=None, # see below
logger=None, # default: logging.getLogger("unicom360")
)
- A missing or blank API key raises
ConfigurationErrorwhen the client is built. An explicitapi_key=""does not fall back to the environment. base_urlmust behttps; plainhttpis allowed only forlocalhost,127.0.0.0/8and::1(for a local mock). A path prefix is kept, trailing slashes are dropped, and a query string or fragment is refused.- Redirects are never followed, so the API key is never sent to another host.
- The client is immutable and safe to share between threads.
client.with_options(timeout=5)returns a copy with other options. app_infois appended to the User-Agent (unicom360-python/0.1.0 (CPython 3.12.4; linux; x86_64) shopsync/2.3.0 (+https://shopsync.example)).- The API key never appears in
repr(client), in exceptions or in logs.
Transport. The default transport uses urllib.request, honours the usual proxy environment
variables (HTTPS_PROXY, NO_PROXY) and takes an ssl.SSLContext
(UrllibTransport(ssl_context=...)). Any object with a send(request) -> Response method can
replace it (another HTTP library, a fake in your tests); raise unicom360.TransportError(sent=..., timeout=...) when no response arrives, saying whether the request may have reached the server.
Async. Version 0.1 is synchronous. An AsyncUnicom360 on an optional httpx extra is planned
for 0.2; until then, call the client from a worker thread (asyncio.to_thread).
Logging
The SDK logs through the standard logging module, on the "unicom360" logger (or the
logger= you pass), and adds no handlers of its own:
import logging
logging.basicConfig()
logging.getLogger("unicom360").setLevel(logging.DEBUG)
- DEBUG: one line per attempt with the method, path, status (or the transport failure),
api_reference, attempt number and duration, plus any generateduser_reference_id. - INFO: one line per scheduled retry with the attempt, the wait and the cause.
Nothing else is logged: never a body, a header, a parameter, a phone number, an address, the API key or a webhook secret.
Known differences from the published docs
This SDK follows the gateway's actual behaviour (as written down in openapi.yaml). Where the
API reference at unicom360.com/docs says otherwise:
- Request-shape errors can carry several messages. A 400 from the SMS or email endpoint can
list one message per problem;
InvalidRequestError.messagejoins them with"; ". - WhatsApp balances are whole numbers today. The docs say they can be fractional (
37.5); the gateway currently rounds them. Balances areDecimal, so fractional values will work unchanged when the gateway sends them. - Local number formats. Only local numbers starting
095,096,097and076are normalised; another local form (for example077...) comes backInvalid mobile number. Send international numbers (2609...). - The SMS and email
callback_urlis not validated by the gateway (the WhatsApp one is). - The WhatsApp
callback_urllimit of 300 is in bytes, so a non-ASCII URL reaches it sooner. - A session
textthat is only whitespace is refused, like an empty one. - The health
timestamphas microseconds (...30.412345Z), not milliseconds. - A session
DuplicateReferenceErrorcan haveoriginal_message_id = Nonewhen two requests race for the same reference. - Rejected template recipients echo the request's values, which can be numbers; the SDK
always gives
toanduser_reference_idas strings. - The balance endpoints can refuse with
CLIENT_NOT_FOUND(HTTP 200,status: false, noapi_reference), raised asAPIError. - An email
user_reference_iddoes not stop two overlapping requests from both being accepted (a sequential repeat is refused). That is why email is never retried after the request may have reached the gateway.
Versioning
The SDK follows Semantic Versioning and starts at 0.1.0; 1.0.0 follows once live use shows no differences from the spec. Changes are listed in CHANGELOG.md. Security reports: see SECURITY.md. Contributing: see CONTRIBUTING.md.
Licence
MIT, see LICENSE. Copyright (c) 2026 Techrode Solutions Limited.
Metadata
Release files for unicom360 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| unicom360-0.1.0.tar.gz | 42.1 kB | With provenance | Details for unicom360-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| unicom360-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for unicom360-0.1.0-py3-none-any.whl |
Total release size: 89.0 kB
Release files / unicom360-0.1.0.tar.gz
| Download URL | unicom360-0.1.0.tar.gz |
|---|---|
| Size | 42.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b468e55ae84e585c9683fcfbd94cb61defbf02f70c016bd4cca0a5c6fbe23290
|
|
BLAKE2b-256 checksum How to use checksums |
665a669975583acf1338f72fb13b551142ed03c5192cf9f4772d58008d24233d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 4, 2026.
Transparency logRelease files / unicom360-0.1.0-py3-none-any.whl
| Download URL | unicom360-0.1.0-py3-none-any.whl |
|---|---|
| Size | 47.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
cb1256f4dbf367fac7a4136774d31ac2e53d479173d62abff32b6c7907e39958
|
|
BLAKE2b-256 checksum How to use checksums |
ea7ef23cf075fcf9186c55352b7a7198ce19018d34fd847cb26e43cad7ec08f4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 4, 2026.
Transparency logSecurity for unicom360 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for unicom360-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 4, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.