Skip to main content

kwtsms Python

PyPI Downloads Python License CI CodeQL GitGuardian

Official Python client for the kwtSMS API, the Kuwait SMS gateway.

Zero external dependencies. Python 3.8+.


About kwtSMS

kwtSMS is a Kuwaiti SMS gateway trusted by top businesses to deliver messages anywhere in the world, with private Sender ID, free API testing, non-expiring credits, and competitive flat-rate pricing. Secure, simple to integrate, built to last. Open a free account in under 1 minute, no paperwork or payment required. Click here to get started 👍


Prerequisites

You need Python 3.8 or newer installed.

python3 --version

If you see a version number (e.g., Python 3.12.3), you're ready. If not, install Python:


Install

Using pip (included with Python)

pip comes bundled with Python 3.4+. Verify it's available:

pip --version

If not found, try pip3 --version. If still missing:

python3 -m ensurepip --upgrade

Then install kwtsms:

pip install kwtsms

uv is a fast Python package manager written in Rust. Install it first:

# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

# Windows (PowerShell)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

# Or with pip
pip install uv

Then install kwtsms:

uv add kwtsms

Using poetry

Install poetry first if you don't have it:

# macOS / Linux
curl -sSL https://install.python-poetry.org | python3 -

# Windows (PowerShell)
(Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | python -

# Or with pip
pip install poetry

Then install kwtsms:

poetry add kwtsms

Using pipenv

Install pipenv first if you don't have it:

pip install pipenv

Then install kwtsms:

pipenv install kwtsms

Quick start

from kwtsms import KwtSMS

sms = KwtSMS.from_env()                                    # reads .env or env vars
ok, balance, error = sms.verify()                           # test credentials
result = sms.send("965XXXXXXXX", "Your OTP for MYAPP is: 123456")  # send SMS

Setup

Create a .env file in your project root (or set the same keys as environment variables):

KWTSMS_USERNAME=your_api_user
KWTSMS_PASSWORD=your_api_pass
KWTSMS_SENDER_ID=YOUR-SENDERID   # use KWT-SMS for testing only
KWTSMS_TEST_MODE=1                # 1 = test (safe default), 0 = live
KWTSMS_LOG_FILE=kwtsms.log        # JSONL log path, set to "" to disable

from_env() checks environment variables first, then the .env file as fallback.


Credential Management

Never hardcode credentials in your source code. Credentials must be changeable without modifying code or redeploying.

# Option 1: Environment variables / .env file (recommended)
sms = KwtSMS.from_env()

# Option 2: Constructor (for custom config systems, DI containers, etc.)
sms = KwtSMS(
    username="python_username",
    password="python_password",
    sender_id="YOUR-SENDERID",  # default "KWT-SMS" (testing only)
    test_mode=False,             # default False
    log_file="kwtsms.log",       # default "kwtsms.log", "" to disable
)

For web apps and SaaS: Provide an admin settings page where API credentials can be updated without touching code. Include a "Test Connection" button that calls verify().

For production: Use a secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.) and pass credentials to the constructor.

User-Agent

Every API request carries the User-Agent kwtsms-python/<version>: the library name and version only, no personal or customer data. Pass your own product token as app_name to put it in front (works for AsyncKwtSMS too):

sms = KwtSMS("python_username", "python_password", app_name="my-shop/2.1.0")
# User-Agent: my-shop/2.1.0 kwtsms-python/0.7.40

Methods

verify() → (ok, balance, error)

Tests credentials by calling the balance endpoint.

ok, balance, error = sms.verify()
if ok:
    print(f"Balance: {balance}")   # float
else:
    print(error)  # "Authentication error... → Check KWTSMS_USERNAME..."

Returns (True, float, None) on success, (False, None, str) on failure. Never raises.


send(mobile, message, sender=None) → dict

Send SMS to one or more numbers.

# Single number
result = sms.send("965XXXXXXXX", "Your OTP for MYAPP is: 123456")

# Multiple numbers (list)
result = sms.send(["965XXXXXXXX", "+96512345678", "0096511111111"], "Hello!")

# Override sender ID for this call only
result = sms.send("965XXXXXXXX", "Hello", sender="MY-APP")

Phone numbers are normalized automatically: strips +, 00, spaces, dashes; converts Arabic/Hindi digits to Latin.

Message text is cleaned automatically: strips emojis, hidden control characters (BOM, zero-width space, soft hyphen), HTML tags; converts Arabic/Hindi digits to Latin.

OK response (≤200 numbers):

{
    "result":         "OK",
    "msg-id":         "f4c841adee210f31...",  # save this: needed for status/DLR lookups
    "numbers":        1,
    "points-charged": 1,
    "balance-after":  149,                    # save this: no need to call balance() again
    "unix-timestamp": 1741000800,             # ⚠ GMT+3 server time, NOT UTC
}

ERROR response:

{
    "result":      "ERROR",
    "code":        "ERR003",
    "description": "Authentication error, username or password are not correct.",
    "action":      "Wrong API username or password. Check KWTSMS_USERNAME and KWTSMS_PASSWORD...",
}

Mixed valid/invalid input: invalid numbers are reported, not raised:

result = sms.send(["965XXXXXXXX", "abc", "user@gmail.com"], "Hello")
# result["invalid"] → [
#   {"input": "abc",            "error": "'abc' is not a valid phone number, no digits found"},
#   {"input": "user@gmail.com", "error": "'user@gmail.com' is an email address, not a phone number"},
# ]

Never raises. Network failures are returned as {"result": "ERROR", "code": "NETWORK", "description": "...", "action": "..."}.


Bulk send (>200 numbers)

send() detects the count automatically and batches in groups of 200. No special call needed.

result = sms.send(list_of_1000_numbers, "Hello!")

if result.get("bulk"):
    print(result["result"])          # "OK", "PARTIAL", or "ERROR"
    print(result["batches"])         # 5  (number of API calls made)
    print(result["numbers"])         # 950 (total numbers accepted)
    print(result["points-charged"])  # 950 (total credits used)
    print(result["balance-after"])   # balance after last batch
    print(result["msg-ids"])         # ["abc123", "def456", ...]  one per batch
    for err in result["errors"]:
        print(err["batch"], err["code"], err["description"])
  • Rate: 0.5s between batches (≤2 req/s)
  • ERR013 (queue full): auto-retries up to 3× with 30s / 60s / 120s backoff
  • "PARTIAL" means some batches succeeded and some failed. Check errors

balance() → float | None

Returns current balance. Returns None on error (does not raise). Also updated automatically after every successful send(), so no need to call this after sending.

bal = sms.balance()

validate(phones) → dict

Validate phone numbers before sending. Numbers that fail local validation (email, too short, no digits) are rejected before any API call.

report = sms.validate(["965XXXXXXXX", "+96512345678", "abc", "123"])

report["ok"]       # ["965XXXXXXXX", "96512345678"]  : valid and routable
report["er"]       # ["abc", "123"]                  : format error
report["nr"]       # []                              : no route for country
report["rejected"] # [{"input": "abc",  "error": "..."},
                   #  {"input": "123",  "error": "'123' is too short..."}]
report["error"]    # None if API call succeeded
report["raw"]      # full raw API response dict, or None if no API call was made

senderids() → dict

Returns the sender IDs registered on this account.

result = sms.senderids()
if result["result"] == "OK":
    print(result["senderids"])  # → ["KWT-SMS", "MY-APP"]
else:
    print(result["action"])

coverage() → dict

Returns active country prefixes allowed on this account.

result = sms.coverage()
if result["result"] == "OK":
    print(result["prefixes"])  # → ["965", "966", "971", "973", "974"]
else:
    print(result["action"])    # ERR033 = no active coverage, contact kwtSMS

status(msg_id) → dict

Get delivery status for a sent message. Uses the /report/ endpoint.

delivery = sms.status(result["msg-id"])
if delivery["result"] == "OK":
    print(delivery["status"])        # "DELIVERED", "FAILED", "PENDING", "REJECTED"
    print(delivery["delivered-at"])  # unix timestamp (GMT+3)
else:
    print(delivery["action"])

Returns OK or ERROR dict. Never raises. Common error codes: ERR019 (no reports yet), ERR020 (ID not found), ERR021 (not ready yet).


send_with_retry(mobile, message, sender=None, max_retries=0, wait_seconds=10) → dict

Send SMS with optional ERR028 retry. ERR028 comes from Duplicate SMS protection, which is opt-in per account (in the account's API settings). When it is on and any number in the request was sent the same message within the account's window (10 seconds by default, configurable per account), the whole request is rejected with ERR028 and nothing is charged. Retry is off by default (max_retries=0), so ERR028 is returned like any other error. Pass max_retries to retry, and set wait_seconds to your account's window.

result = sms.send_with_retry("965XXXXXXXX", "Your OTP is: 123456", max_retries=1, wait_seconds=10)
# if ERR028 occurs: waits 10 s and retries once

Non-ERR028 errors are returned immediately without retry. Never raises.


parse_webhook(payload) → dict

Parse a kwtSMS delivery receipt webhook payload. kwtSMS can POST delivery receipts to your server as JSON.

from kwtsms import parse_webhook

# In your webhook endpoint (Flask/FastAPI/Django):
result = parse_webhook(request.json)
if result["ok"]:
    print(result["msg_id"])        # matches the msg-id from send()
    print(result["phone"])         # recipient number
    print(result["status"])        # "DELIVERED", "FAILED", "PENDING", "REJECTED"
    print(result["delivered_at"])  # unix timestamp, or None
else:
    print(result["error"])         # "Missing required field: 'msg-id'"

AsyncKwtSMS

Async version of KwtSMS for use with asyncio. Requires the optional aiohttp dependency:

pip install kwtsms[async]
from kwtsms import AsyncKwtSMS

sms = AsyncKwtSMS.from_env()

async def send_otp(phone: str, code: str):
    ok, balance, error = await sms.verify()
    result = await sms.send(phone, f"Your OTP is: {code}")
    delivery = await sms.status(result["msg-id"])
    return result

AsyncKwtSMS mirrors KwtSMS exactly: verify(), balance(), send(), status(), from_env(), purchased property. Maximum 200 numbers per send() call (no auto-batching in async mode). All methods return dicts and never raise.


Utility functions

from kwtsms import (normalize_phone, validate_phone_input, clean_message,
                     parse_webhook, find_country_code, validate_phone_format)

# Normalize a phone number: strips +, 00, spaces, dashes; converts Arabic digits;
# strips domestic trunk prefix (e.g. 9660559... becomes 966559...)
normalize_phone("+965XXXXXXXX")      # → "965XXXXXXXX"
normalize_phone("00 965 XXXX-XXXX") # → "965XXXXXXXX"
normalize_phone("٩٦٥XXXXXXXX")     # → "965XXXXXXXX"
normalize_phone("9660559123456")    # → "966559123456"  (Saudi trunk 0 stripped)

# Validate a phone number: returns (is_valid, error, normalized)
ok, error, number = validate_phone_input("+965XXXXXXXX")
# → (True, None, "965XXXXXXXX")

ok, error, number = validate_phone_input("user@gmail.com")
# → (False, "'user@gmail.com' is an email address, not a phone number", "")

ok, error, number = validate_phone_input("123")
# → (False, "'123' is too short to be a valid phone number (3 digits, minimum is 7)", "123")

ok, error, number = validate_phone_input("96512345678")
# → (False, "Invalid Kuwait mobile number: after +965 must start with 4, 5, 6, 9", "96512345678")

# Find country code from a normalized number
find_country_code("965XXXXXXXX")    # → "965" (Kuwait)
find_country_code("12125551234")    # → "1"   (USA/Canada)
find_country_code("8887654321")     # → None  (unknown)

# Validate phone format against country-specific rules (length + mobile prefix)
valid, error = validate_phone_format("965XXXXXXXX")   # → (True, None)
valid, error = validate_phone_format("96512345678")   # → (False, "Invalid Kuwait mobile...")

# Clean message text: also called automatically inside send()
clean_message("Your OTP is: ١٢٣٤٥٦ 🎉")  # → "Your OTP is: 123456 "

Input sanitization

Phone numbers

All phone numbers are normalized automatically before every API call:

  1. Arabic/Hindi digits (٠١٢٣٤٥٦٧٨٩ / ۰۱۲۳۴۵۶۷۸۹) → Latin (0123456789)
  2. All non-digit characters stripped (+, spaces, dashes, dots, brackets, etc.)
  3. Leading zeros stripped (handles 00 country code prefix)
  4. Domestic trunk prefix stripped after country code (e.g., 9660559... → 966559...)
  5. Country-specific validation: checks local number length and mobile prefix for 80+ countries

Numbers must include the country code (e.g., 965XXXXXXXX for Kuwait, not XXXXXXXX).

Message text

send() calls clean_message() automatically before every API call. Three types of content cause silent delivery failure (API returns OK, message stuck in queue, credits wasted):

Content Effect What happens
Emojis Stuck in queue indefinitely, no error returned Stripped automatically
Hidden characters (zero-width space, BOM, soft hyphen) Spam filter rejection or queue stuck Stripped automatically
Arabic/Hindi digits in body (١٢٣٤) OTP codes may render inconsistently Converted to Latin automatically
HTML tags ERR027, message rejected Stripped automatically

Arabic letters are fully supported and are NOT stripped.


CLI

For a standalone cross-platform CLI, see kwtsms-cli.


Error handling

Every API error response includes an action field with guidance. send() never raises: network failures are returned as a dict with code="NETWORK".

result = sms.send("965XXXXXXXX", "Your OTP for MYAPP is: 123456")
if result["result"] == "OK":
    save_to_db(msg_id=result["msg-id"], balance=result["balance-after"])
else:
    print(result["code"])        # e.g. "ERR010" or "NETWORK"
    print(result["description"]) # "Account balance is zero."
    print(result["action"])      # "Recharge credits at kwtsms.com."

Common error codes:

Code Meaning
ERR003 Wrong username or password
ERR006 No valid phone numbers, missing country code
ERR008 Sender ID is banned or not found (case sensitive)
ERR010 Zero balance
ERR011 Insufficient balance
ERR025 Invalid phone number, missing country code
ERR026 Country not activated on this account
ERR028 Sent to the same number too soon.

Phone number formats

All formats are accepted. Numbers are normalized automatically:

Input Sent as
+965XXXXXXXX 965XXXXXXXX
00965XXXXXXXX 965XXXXXXXX
965 XXXX XXXX 965XXXXXXXX
965-XXXX-XXXX 965XXXXXXXX
٩٦٥XXXXXXXX (Arabic digits) 965XXXXXXXX

Numbers must include the country code. XXXXXXXX (local) will be rejected by the API. Use 965XXXXXXXX.


Test mode

Set KWTSMS_TEST_MODE=1 or test_mode=True. Test messages are queued but not delivered. Their credits are held until you delete them from the Sending Queue in your kwtsms.com account.

sms = KwtSMS.from_env()   # KWTSMS_TEST_MODE=1 in .env
result = sms.send("965XXXXXXXX", "Test message")
# Message is queued: visible in kwtsms.com → Account → Queue
# Delete it from the queue to recover credits

Set KWTSMS_TEST_MODE=0 before going live.


Sender ID

KWT-SMS is a shared sender for testing only. It can cause delays and is blocked on some Kuwait carriers. Register a private sender ID on kwtsms.com before going live.

Sender IDs are case sensitive: Kuwait is not the same as KUWAIT or kuwait.

Promotional Transactional
Use for Bulk SMS, marketing, offers OTP, alerts, notifications
Delivery to DND numbers Blocked, credits lost Bypasses DND (whitelisted)
Speed May have delays Priority delivery
Cost 10 KD one-time 15 KD one-time

For OTP/authentication, you must use a Transactional sender ID. Using Promotional for OTP means messages to DND numbers are silently blocked and credits are still deducted.


Best practices

Validate locally before calling the API

Don't send invalid data to the API. Validate first to avoid wasted API calls:

from kwtsms import validate_phone_input, clean_message

# Check phone number before sending
ok, error, normalized = validate_phone_input(user_input)
if not ok:
    show_user_error(error)  # "Phone number is required", "'abc' is not a phone number", etc.
    return

# Check country is active (cache prefixes at startup)
if not any(normalized.startswith(p) for p in cached_prefixes):
    show_user_error("SMS delivery to this country is not available.")
    return

# Check message is not empty after cleaning
message = clean_message(user_input_message)
if not message.strip():
    show_user_error("Message is empty.")
    return

result = sms.send(normalized, message)  # only valid input reaches the API

Save msg-id and balance-after from every send

if result["result"] == "OK":
    db.save("sms_balance", result["balance-after"])   # track balance: no extra API call needed
    db.save_message(msg_id=result["msg-id"], ...)     # needed for status checks later

OTP messages

  • Always include your app/company name: "Your OTP for APPNAME is: 123456"
  • Wait at least 3-4 minutes before allowing resend
  • Generate a new code on each resend, and invalidate all previous codes
  • Use a Transactional sender ID (not Promotional)
  • Send to one number per request (avoid ERR028 in batches)

User-facing error messages

Don't show raw API errors to end users:

Situation Show to user Show to admin/logs
Invalid phone "Please enter a valid phone number with country code" The actual validation error
Auth error (ERR003) "SMS service temporarily unavailable" Log the error + alert admin
No balance (ERR010/011) "SMS service temporarily unavailable" Alert admin to recharge
Rate limited (ERR028) "Please wait before requesting another code" Log the rate limit hit

Security checklist

Before going live, make sure:

  • CAPTCHA enabled on all forms that trigger SMS (OTP, signup, password reset)
  • Rate limit per phone number (max 3-5 requests/hour)
  • Rate limit per IP address (max 10-20 requests/hour)
  • Monitoring/alerting on failed sends and balance depletion
  • Test mode OFF (KWTSMS_TEST_MODE=0)
  • Private sender ID registered (not KWT-SMS)
  • Transactional sender ID for OTP (not Promotional)
  • Credentials in .env or env vars (not hardcoded)

What's handled automatically

  • Phone normalization (strips +, 00, spaces, dashes; converts Arabic/Hindi digits)
  • Input validation (catches emails, empty strings, too short/long, before the API is called)
  • Message cleaning (strips emojis, hidden control characters, HTML tags; converts Arabic digits)
  • API error enrichment (action field added to every error response)
  • Bulk batching (auto-splits lists >200 numbers into batches of 200, 0.5s between batches)
  • ERR013 backoff (queue full: retries 3× at 30s / 60s / 120s automatically)
  • Balance caching (every send response includes balance-after, no extra API call needed)
  • JSONL logging (one line per API call, password always masked, timestamps in UTC)

Note: unix-timestamp in API responses is GMT+3 (Asia/Kuwait server time), not UTC. Log ts fields written by this client are always UTC ISO-8601.


Logging

One JSON line per API call written to kwtsms.log (or the path in KWTSMS_LOG_FILE). Password is always masked.

{"ts":"2026-03-04T10:00:00+00:00","endpoint":"send","request":{"username":"python_username","password":"***","sender":"MYAPP","mobile":"965XXXXXXXX","message":"Your OTP is: 123456","test":"0"},"response":{"result":"OK","msg-id":"f4c841ad...","numbers":1,"points-charged":1,"balance-after":149,"unix-timestamp":1741082400},"ok":true,"error":null}

ts is always UTC. unix-timestamp inside response is GMT+3 (Asia/Kuwait server time).

Set log_file="" or KWTSMS_LOG_FILE= to disable logging.


FAQ

1. My message was sent successfully (result: OK) but the recipient didn't receive it. What happened?

Check the Sending Queue at kwtsms.com. If your message is stuck there, it was accepted by the API but not dispatched. Common causes are emoji in the message, hidden characters from copy-pasting, or spam filter triggers. Delete it from the queue to recover your credits. Also verify that test mode is off (KWTSMS_TEST_MODE=0). Test messages are queued but never delivered.

2. What is the difference between Test mode and Live mode?

Test mode (KWTSMS_TEST_MODE=1) sends your message to the kwtSMS queue but does NOT deliver it to the handset. Its credits are held until you delete it from the Sending Queue in your kwtsms.com account. Use this during development. Live mode (KWTSMS_TEST_MODE=0) delivers the message for real and deducts credits. Always develop in test mode and switch to live only when ready for production.

3. What is a Sender ID and why should I not use "KWT-SMS" in production?

A Sender ID is the name that appears as the sender on the recipient's phone (e.g., "MY-APP" instead of a random number). KWT-SMS is a shared test sender. It causes delivery delays, is blocked on Virgin Kuwait, and should never be used in production. Register your own private Sender ID through your kwtSMS account. For OTP/authentication messages, you need a Transactional Sender ID to bypass DND (Do Not Disturb) filtering.

4. I'm getting ERR003 "Authentication error". What's wrong?

You are using the wrong credentials. The API requires your API username and API password, NOT your account mobile number. Log in to kwtsms.com, go to Account → API settings, and check your API credentials. Also make sure you are using POST (not GET) and Content-Type: application/json.

5. Can I send to international numbers (outside Kuwait)?

International sending is disabled by default on kwtSMS accounts. Contact kwtSMS support to request activation for specific country prefixes. Use coverage() to check which countries are currently active on your account. Be aware that activating international coverage increases exposure to automated abuse, so implement rate limiting and CAPTCHA before enabling.


Help & Support


Repository layout

kwtsms_python/
├── src/kwtsms/
│   ├── _core.py      ← KwtSMS class + all sync logic
│   ├── _async.py     ← AsyncKwtSMS (requires kwtsms[async])
│   └── __init__.py   ← public exports
├── tests/            ← pytest test suite
├── examples/         ← runnable example scripts
├── pyproject.toml
├── uv.lock
├── README.md
└── LICENSE

License

MIT

Metadata

Release files for kwtsms 0.8.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for kwtsms 0.8.0
File Size Uploaded Provenance Actions
kwtsms-0.8.0.tar.gz 79.6 kB With provenance Details for kwtsms-0.8.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for kwtsms 0.8.0
File Interpreter ABI Platform Actions
kwtsms-0.8.0-py3-none-any.whl Python 3 none any Details for kwtsms-0.8.0-py3-none-any.whl

Total release size: 109.7 kB

Release files / kwtsms-0.8.0.tar.gz

Download URL kwtsms-0.8.0.tar.gz
Size 79.6 kB
Tags Source
SHA-256 checksum
How to use checksums
52cbe4d7070fab3eb5682066b4c9ef5bc11b234d86438955f6659d844fd15075
BLAKE2b-256 checksum
How to use checksums
00249e69f59ee18d8d94e7bf9b395db47cd3e3833f3400b500418d9a60cd44b3
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 6, 2026.

Transparency log

Release files / kwtsms-0.8.0-py3-none-any.whl

Download URL kwtsms-0.8.0-py3-none-any.whl
Size 30.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
fb026052d184a93d8a30f850e5068de43fded158448af1743e95d7ce619b6ba3
BLAKE2b-256 checksum
How to use checksums
54bd158feddf1410a8ed4b08350a551d665add75b3173643f55ada48d35d41ce
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 6, 2026.

Transparency log

Security for kwtsms 0.8.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.

Select from 2 files

Attestation for kwtsms-0.8.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 6, 2026.

Transparency log

A 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.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page