Skip to main content

Official Sendly Python SDK for SMS messaging

Project description

Sendly Python SDK

PyPI version license

sendly

Official Python SDK for the Sendly SMS API.

Installation

# pip
pip install sendly

# poetry
poetry add sendly

# pipenv
pipenv install sendly

Requirements

Quick Start

from sendly import Sendly

# Initialize with your API key
client = Sendly('sk_live_v1_your_api_key')

# Send an SMS
message = client.messages.send(
    to='+15551234567',
    text='Hello from Sendly!'
)

print(f'Message sent: {message.id}')
print(f'Status: {message.status}')

Prerequisites for Live Messaging

Before sending live SMS messages, you need:

  1. Business Verification - Complete verification in the Sendly dashboard

    • International: Instant approval (just provide Sender ID)
    • US/Canada: Requires carrier approval (3-7 business days)
  2. Credits - Add credits to your account

    • Test keys (sk_test_*) work without credits (sandbox mode)
    • Live keys (sk_live_*) require credits for each message
  3. Live API Key - Generate after verification + credits

    • Dashboard → API Keys → Create Live Key

Test vs Live Keys

Key Type Prefix Credits Required Verification Required Use Case
Test sk_test_v1_* No No Development, testing
Live sk_live_v1_* Yes Yes Production messaging

Note: You can start development immediately with a test key. Messages to sandbox test numbers are free and don't require verification.

Features

  • ✅ Full type hints (PEP 484)
  • ✅ Sync and async clients
  • ✅ Automatic retries with exponential backoff
  • ✅ Rate limit handling
  • ✅ Pydantic models for data validation
  • ✅ Python 3.8+ support

Usage

Sending Messages

from sendly import Sendly

client = Sendly('sk_live_v1_xxx')

# Basic usage (marketing message - default)
message = client.messages.send(
    to='+15551234567',
    text='Check out our new features!'
)

# Transactional message (bypasses quiet hours)
message = client.messages.send(
    to='+15551234567',
    text='Your verification code is: 123456',
    message_type='transactional'
)

# With custom sender ID (international)
message = client.messages.send(
    to='+447700900123',
    text='Hello from MyApp!',
    from_='MYAPP'
)

# With custom metadata (max 4KB)
message = client.messages.send(
    to='+15551234567',
    text='Your order #12345 has shipped!',
    metadata={
        'order_id': '12345',
        'customer_id': 'cust_abc'
    }
)

Listing Messages

# Get recent messages (default limit: 50)
result = client.messages.list()
print(f'Found {result.count} messages')

# Get last 10 messages
result = client.messages.list(limit=10)

# Iterate through messages
for msg in result.data:
    print(f'{msg.to}: {msg.status}')

Getting a Message

message = client.messages.get('msg_xxx')

print(f'Status: {message.status}')
print(f'Delivered: {message.delivered_at}')

Scheduling Messages

# Schedule a message for future delivery
scheduled = client.messages.schedule(
    to='+15551234567',
    text='Your appointment is tomorrow!',
    scheduled_at='2025-01-15T10:00:00Z'
)

print(f'Scheduled: {scheduled.id}')
print(f'Will send at: {scheduled.scheduled_at}')

# List scheduled messages
result = client.messages.list_scheduled()
for msg in result.data:
    print(f'{msg.id}: {msg.scheduled_at}')

# Get a specific scheduled message
msg = client.messages.get_scheduled('sched_xxx')

# Cancel a scheduled message (refunds credits)
result = client.messages.cancel_scheduled('sched_xxx')
print(f'Refunded: {result.credits_refunded} credits')

Batch Messages

# Send multiple messages in one API call (up to 1000)
batch = client.messages.send_batch(
    messages=[
        {'to': '+15551234567', 'text': 'Hello User 1!'},
        {'to': '+15559876543', 'text': 'Hello User 2!'},
        {'to': '+15551112222', 'text': 'Hello User 3!'}
    ]
)

print(f'Batch ID: {batch.batch_id}')
print(f'Queued: {batch.queued}')
print(f'Failed: {batch.failed}')
print(f'Credits used: {batch.credits_used}')

# Get batch status
status = client.messages.get_batch('batch_xxx')

# List all batches
result = client.messages.list_batches()

# Preview batch (dry run) - validates without sending
preview = client.messages.preview_batch(
    messages=[
        {'to': '+15551234567', 'text': 'Hello User 1!'},
        {'to': '+447700900123', 'text': 'Hello UK!'}
    ]
)
print(f"Credits needed: {preview['creditsNeeded']}")
print(f"Will send: {preview['willSend']}, Blocked: {preview['blocked']}")

Rate Limit Information

# After any API call, check rate limit status
client.messages.send(to='+1555...', text='Hello!')

rate_limit = client.get_rate_limit_info()
if rate_limit:
    print(f'{rate_limit.remaining}/{rate_limit.limit} requests remaining')
    print(f'Resets in {rate_limit.reset} seconds')

Async Client

For async/await support, use AsyncSendly:

import asyncio
from sendly import AsyncSendly

async def main():
    async with AsyncSendly('sk_live_v1_xxx') as client:
        # Send a message
        message = await client.messages.send(
            to='+15551234567',
            text='Hello from async!'
        )
        print(message.id)

        # List messages
        result = await client.messages.list(limit=10)
        for msg in result.data:
            print(f'{msg.to}: {msg.status}')

asyncio.run(main())

Configuration

from sendly import Sendly, SendlyConfig

# Using keyword arguments
client = Sendly(
    api_key='sk_live_v1_xxx',
    base_url='https://sendly.live/api/v1',  # Optional
    timeout=60.0,  # Optional: seconds (default: 30)
    max_retries=5  # Optional: (default: 3)
)

# Using config object
config = SendlyConfig(
    api_key='sk_live_v1_xxx',
    timeout=60.0,
    max_retries=5
)
client = Sendly(config=config)

Webhooks

Manage webhook endpoints to receive real-time delivery status updates.

# Create a webhook endpoint
webhook = client.webhooks.create(
    url='https://example.com/webhooks/sendly',
    events=['message.delivered', 'message.failed']
)

print(f'Webhook ID: {webhook.id}')
print(f'Secret: {webhook.secret}')  # Store this securely!

# List all webhooks
webhooks = client.webhooks.list()

# Get a specific webhook
wh = client.webhooks.get('whk_xxx')

# Update a webhook
client.webhooks.update('whk_xxx',
    url='https://new-endpoint.example.com/webhook',
    events=['message.delivered', 'message.failed', 'message.sent']
)

# Test a webhook (sends a test event)
result = client.webhooks.test('whk_xxx')
print(f'Test {"passed" if result.success else "failed"}')

# Rotate webhook secret
rotation = client.webhooks.rotate_secret('whk_xxx')
print(f'New secret: {rotation.secret}')

# View delivery history
deliveries = client.webhooks.get_deliveries('whk_xxx')

# Retry a failed delivery
client.webhooks.retry_delivery('whk_xxx', 'del_yyy')

# Delete a webhook
client.webhooks.delete('whk_xxx')

Verifying Webhook Signatures

from sendly import Webhooks

WEBHOOK_SECRET = 'your_webhook_secret'

# In your webhook handler (Flask example)
@app.route('/webhooks/sendly', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Sendly-Signature')
    timestamp = request.headers.get('X-Sendly-Timestamp')
    payload = request.get_data(as_text=True)

    try:
        event = Webhooks.parse_event(payload, signature, WEBHOOK_SECRET, timestamp=timestamp)

        if event.type == 'message.delivered':
            print(f'Message {event.data.id} delivered')
        elif event.type == 'message.failed':
            print(f'Message {event.data.id} failed: {event.data.error_code}')

        return 'OK', 200
    except Exception as e:
        print(f'Invalid signature: {e}')
        return 'Invalid signature', 400

Account & Credits

# Get account information
account = client.account.get()
print(f'Email: {account.email}')

# Check credit balance
credits = client.account.get_credits()
print(f'Available: {credits.available_balance} credits')
print(f'Reserved (scheduled): {credits.reserved_balance} credits')
print(f'Total: {credits.balance} credits')

# View credit transaction history
transactions = client.account.get_credit_transactions()
for tx in transactions:
    print(f'{tx.type}: {tx.amount} credits - {tx.description}')

# List API keys
keys = client.account.list_api_keys()
for key in keys:
    print(f'{key.name}: {key.prefix}*** ({key.type})')

# Get API key usage stats
usage = client.account.get_api_key_usage('key_xxx')
print(f"Messages sent: {usage['messagesSent']}")
print(f"Credits used: {usage['creditsUsed']}")

# Create a new API key
result = client.account.create_api_key('Production Key')
print(f"New key: {result['key']}")  # Only shown once!

# Rotate an API key (old key keeps working for a 24h grace period by default)
rotation = client.account.rotate_api_key('key_xxx')
print(f"New key: {rotation['newKey']['key']}")  # Only shown once!

# Rotate with a wider overlap window (24-168 hours)
rotation = client.account.rotate_api_key('key_xxx', grace_period_hours=72)

# Revoke an API key
client.account.revoke_api_key('key_xxx')

Phone Numbers

Search for and buy phone numbers. Prices on available numbers are already customer-priced. Requires an API key with the numbers:read / numbers:write scopes.

# List the countries where numbers are available
countries = client.numbers.list_countries()
for country in countries.countries:
    print(f'{country.code} {country.name}: {country.number_types}')

# Search for available numbers (already customer-priced)
available = client.numbers.list_available(country='GB', type='mobile')
for num in available.numbers:
    print(f'{num.phone_number}{num.monthly_cost} {num.currency}')

# Optionally filter by a digit pattern
available = client.numbers.list_available(country='GB', type='mobile', contains='777')

# List the numbers you already own
owned = client.numbers.list()
for num in owned.numbers:
    print(f'{num.phone_number} ({num.status})')

# Get one owned number (includes whether it's your default sender)
number = client.numbers.get('num_xxx')
print(f'{number.phone_number} default={number.is_default}')

# Make a number your default sender
client.numbers.update('num_xxx', is_default=True)

# Cancel a scheduled release and keep the number
client.numbers.update('num_xxx', pending_cancellation=False)

# Release a number (a paid number is kept until the end of the billed period)
result = client.numbers.release('num_xxx')
if result.scheduled:
    print(f'Releases at {result.scheduled_release_at}')
else:
    print('Released')

# Buy a number
chosen = available.numbers[0]
result = client.numbers.buy(
    phone_number=chosen.phone_number,
    country_code='GB',
    phone_number_type='mobile',
    monthly_cost=chosen.monthly_cost,
)

if result.status == 'provisioning':
    print(f'Provisioning {result.number.phone_number}')
elif result.status in ('documents_required', 'payment_required'):
    # Hand the user the hosted Sendly page + code, wait for them to finish,
    # then call buy() again with the same arguments plus action_code set to
    # the completed action's code.
    print(f'Open {result.action.url} and enter code {result.action.code}')
    # ...later, after the user completes the page...
    result = client.numbers.buy(
        phone_number=chosen.phone_number,
        country_code='GB',
        phone_number_type='mobile',
        monthly_cost=chosen.monthly_cost,
        action_code=result.action.code,
    )

10DLC (Local Number Texting)

Register your business for carrier review so you can text from local (10-digit) US numbers. Requires an API key with the tendlc:read / tendlc:write scopes; writes need a live key. The flow has three steps: brand → campaign → assign a number.

# 1. Register a brand and poll until it's verified
brand = client.ten_dlc.create_brand(
    legal_name='Acme Holdings LLC',
    ein='12-3456789',
    website='https://acme.example',
    email='ops@acme.example',
).data
# ...poll client.ten_dlc.get_brand(brand.id) until brand.status == 'verified'
# (or 'failed', with brand.failure_reasons explaining why)

# 2. Pre-check the use case, then create a campaign
check = client.ten_dlc.qualify(brand.id, 'MIXED').data
if check.qualified:
    campaign = client.ten_dlc.create_campaign(
        brand_id=brand.id,
        use_case='MIXED',
        description='Order updates and support replies for Acme customers',
        message_flow='Customers opt in at checkout on acme.example',
        sample_messages=['Your order #123 has shipped!'],
        opt_out_keywords='STOP',
    ).data
    # ...poll client.ten_dlc.get_campaign(campaign.id) until status == 'active'

    # 3. Assign a number you own; it can send once the assignment is 'Active'
    assignment = client.ten_dlc.assign_number(campaign.id, '+15551234567').data
    print(assignment.status)

# List what you have registered
brands = client.ten_dlc.list_brands()
campaigns = client.ten_dlc.list_campaigns()
assignments = client.ten_dlc.list_assignments()

Group MMS

Send one message to 2-8 US/Canada recipients who share a single thread; replies fan out to everyone. Provide text, media_urls, or both. Requires the group_mms feature.

group = client.messages.send_group(
    to=['+14155551234', '+14155555678'],
    text='Dinner at 7?'
)
print(f'{group.id}: {group.status}')  # 'sent' (or 'delivered' on a test key)
print(group.group_message_id)         # stable group thread id, when available

# With media
client.messages.send_group(
    to=['+14155551234', '+14155555678'],
    text='Here is the menu',
    media_urls=['https://example.com/menu.jpg'],
)

AI Enhance

Polish message copy before sending. Pass text and/or a message_type to steer the tone.

result = client.messages.enhance(text='ur order shipped', message_type='transactional')
print(result.enhanced)     # cleaned-up copy
print(result.explanation)  # why it changed

Branded Links (URL Shortener)

Mint branded short links for a destination URL, list them with click analytics, and flip a per-link kill switch. Branded, owned-domain links improve deliverability and give you click data. Gated behind the url_shortener flag.

# Shorten a URL
link = client.links.create('https://example.com/spring-sale?utm_source=sms')
print(link.short_url)        # https://sendly.live/l/Ab3xY7
print(link.code)             # Ab3xY7

# List your links with click counts and a 14-day daily histogram
listing = client.links.list(limit=20)
print(f'{listing.total} links')
for lk in listing.links:
    print(f'{lk.short_url} -> {lk.destination_url} ({lk.click_count} clicks)')

# Kill a link (its redirect 404s until re-enabled)
client.links.disable(link.code)
client.links.enable(link.code)

Error Handling

The SDK provides typed exception classes:

from sendly import (
    Sendly,
    SendlyError,
    AuthenticationError,
    RateLimitError,
    InsufficientCreditsError,
    ValidationError,
    NotFoundError,
)

client = Sendly('sk_live_v1_xxx')

try:
    message = client.messages.send(
        to='+15551234567',
        text='Hello!'
    )
except AuthenticationError as e:
    print(f'Invalid API key: {e.message}')
except RateLimitError as e:
    print(f'Rate limited. Retry after {e.retry_after} seconds')
except InsufficientCreditsError as e:
    print(f'Need {e.credits_needed} credits, have {e.current_balance}')
except ValidationError as e:
    print(f'Invalid request: {e.message}')
except NotFoundError as e:
    print(f'Resource not found: {e.message}')
except SendlyError as e:
    print(f'API error [{e.code}]: {e.message}')

Testing (Sandbox Mode)

Use a test API key (sk_test_v1_xxx) for testing:

from sendly import Sendly, SANDBOX_TEST_NUMBERS

client = Sendly('sk_test_v1_xxx')

# Check if in test mode
print(client.is_test_mode())  # True

# Use sandbox test numbers
message = client.messages.send(
    to=SANDBOX_TEST_NUMBERS.SUCCESS,  # +15005550000
    text='Test message'
)

# Test error scenarios
message = client.messages.send(
    to=SANDBOX_TEST_NUMBERS.INVALID,  # +15005550001
    text='This will fail'
)

Available Test Numbers

Number Behavior
+15005550000 Success (instant)
+15005550001 Fails: invalid_number
+15005550002 Fails: unroutable_destination
+15005550003 Fails: queue_full
+15005550004 Fails: rate_limit_exceeded
+15005550006 Fails: carrier_violation

Pricing Tiers

from sendly import CREDITS_PER_SMS, SUPPORTED_COUNTRIES, PricingTier

# Credits per SMS by tier
print(CREDITS_PER_SMS[PricingTier.DOMESTIC])  # 2 (US/Canada)
print(CREDITS_PER_SMS[PricingTier.TIER1])     # 8 (UK, Poland, etc.)
print(CREDITS_PER_SMS[PricingTier.TIER2])     # 12 (France, Japan, etc.)
print(CREDITS_PER_SMS[PricingTier.TIER3])     # 16 (Germany, Italy, etc.)

# Supported countries by tier
print(SUPPORTED_COUNTRIES[PricingTier.DOMESTIC])  # ['US', 'CA']
print(SUPPORTED_COUNTRIES[PricingTier.TIER1])     # ['GB', 'PL', ...]

Utilities

The SDK exports validation utilities:

from sendly import (
    validate_phone_number,
    get_country_from_phone,
    is_country_supported,
    calculate_segments,
)

# Validate phone number format
validate_phone_number('+15551234567')  # OK
validate_phone_number('555-1234')  # Raises ValidationError

# Get country from phone number
get_country_from_phone('+447700900123')  # 'GB'
get_country_from_phone('+15551234567')   # 'US'

# Check if country is supported
is_country_supported('GB')  # True
is_country_supported('XX')  # False

# Calculate SMS segments
calculate_segments('Hello!')  # 1
calculate_segments('A' * 200)  # 2

Type Hints

The SDK is fully typed. Import types for your IDE:

from sendly import (
    SendlyConfig,
    SendMessageRequest,
    Message,
    MessageStatus,
    ListMessagesOptions,
    MessageListResponse,
    RateLimitInfo,
    PricingTier,
)

Context Manager

Both sync and async clients support context managers:

# Sync
with Sendly('sk_live_v1_xxx') as client:
    message = client.messages.send(to='+1555...', text='Hello!')

# Async
async with AsyncSendly('sk_live_v1_xxx') as client:
    message = await client.messages.send(to='+1555...', text='Hello!')

API Reference

Sendly / AsyncSendly

Constructor

Sendly(
    api_key: str,
    base_url: str = 'https://sendly.live/api/v1',
    timeout: float = 30.0,
    max_retries: int = 3,
)

Properties

  • messages - Messages resource
  • base_url - Configured base URL

Methods

  • is_test_mode() - Returns True if using a test API key
  • get_rate_limit_info() - Returns current rate limit info
  • close() - Close the HTTP client

client.messages

send(to, text, from_=None) -> Message

Send an SMS message.

list(limit=None) -> MessageListResponse

List sent messages.

get(id) -> Message

Get a specific message by ID.

client.numbers

list_countries() -> NumberCountriesResponse

List the countries where numbers can be searched and purchased.

list_available(country, type, contains=None) -> AvailableNumbersResponse

Search for available numbers, already customer-priced.

list() -> OwnedNumbersResponse

List the numbers the account already owns.

buy(phone_number, country_code, phone_number_type, monthly_cost, action_code=None) -> BuyNumberResponse

Buy a number. Returns status of provisioning, documents_required, or payment_required; when documents/payment are required, action carries a hosted page URL + code to hand to the user before re-calling with action_code.

client.ten_dlc

list_brands() -> TenDlcBrandListResponse

List the brands registered for carrier review.

create_brand(legal_name, dba=None, ein=None, entity_type=None, ...) -> TenDlcBrandResponse

Register a brand for carrier review — step 1 of enabling local-number texting. The brand starts pending; poll get_brand() until it becomes verified.

get_brand(id) -> TenDlcBrandResponse

Fetch one brand; also refreshes its carrier-review status, so polling shows progress (pendingverified/failed).

qualify(brand_id, use_case) -> TenDlcQualifyResponse

Pre-check whether a use case qualifies for a brand on the carrier network before creating a campaign.

list_campaigns() -> TenDlcCampaignListResponse

List your messaging campaigns.

create_campaign(brand_id, use_case, description, message_flow, sample_messages, ...) -> TenDlcCampaignResponse

Create a campaign under a verified brand and submit it for carrier review. Starts pending; poll get_campaign() until it becomes active.

get_campaign(id) -> TenDlcCampaignResponse

Fetch one campaign; also refreshes its carrier-review status, including throughput once carriers approve.

assign_number(campaign_id, phone_number) -> TenDlcAssignmentResponse

Assign a number you own to an active campaign, making the number sendable. Idempotent — re-assigning the same number to the same campaign returns the existing assignment.

list_assignments() -> TenDlcAssignmentListResponse

List your number-to-campaign assignments.

Enterprise

The Enterprise API lets you programmatically manage workspaces, verification, credits, and API keys for multi-tenant platforms. Requires an enterprise master key (sk_live_v1_master_*).

Quick Provision

Create a fully configured workspace in a single call:

from sendly import Sendly

client = Sendly('sk_live_v1_master_YOUR_KEY')

# Inherit verification from an existing workspace (fastest)
result = client.enterprise.provision({
    "name": "Acme Insurance - Austin",
    "sourceWorkspaceId": "ws_verified",
    "creditAmount": 5000,
    "creditSourceWorkspaceId": "SOURCE_WORKSPACE_ID",
    "keyName": "Production",
    "keyType": "live",
    "generateOptInPage": True
})

print(result["workspace"]["id"])
print(result["key"]["key"])
print(result["optInPage"]["url"])

Three provisioning modes:

Mode Params Description
Inherit sourceWorkspaceId Shares toll-free number from verified workspace
Inherit + New Number sourceWorkspaceId + inheritWithNewNumber: True Copies business info, purchases new number
Fresh verification: { ... } Full business details, new number + carrier approval

Workspace Management

ws = client.enterprise.workspaces.create(name="Acme Insurance")

result = client.enterprise.workspaces.list()
for ws in result.workspaces:
    print(f"{ws.name}: {ws.verification_status}")

detail = client.enterprise.workspaces.get("ws_xxx")

client.enterprise.workspaces.delete("ws_xxx")

Credits & API Keys

client.enterprise.workspaces.transfer_credits(
    "ws_dest",
    source_workspace_id="ws_source",
    amount=5000,
)

key = client.enterprise.workspaces.create_key(
    "ws_xxx",
    name="Production",
    type="live",
)
print(key.key)

client.enterprise.workspaces.revoke_key("ws_xxx", "key_abc")

Webhooks & Analytics

client.enterprise.webhooks.set(url="https://yourapp.com/webhooks")

overview = client.enterprise.analytics.overview()
messages = client.enterprise.analytics.messages(period="30d")
delivery = client.enterprise.analytics.delivery()

Full enterprise docs: sendly.live/docs/enterprise


Support

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

sendly-3.37.1.tar.gz (94.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sendly-3.37.1-py3-none-any.whl (79.9 kB view details)

Uploaded Python 3

File details

Details for the file sendly-3.37.1.tar.gz.

File metadata

  • Download URL: sendly-3.37.1.tar.gz
  • Upload date:
  • Size: 94.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.15

File hashes

Hashes for sendly-3.37.1.tar.gz
Algorithm Hash digest
SHA256 eb1496a0639ad49f572a1425ce5a5aaf649afca7e481150c5632b51bca226265
MD5 b77fe1e19b35b8847012138ed5f3691f
BLAKE2b-256 112c325da4880514e2243c792da00789854a2f1264046b07f0393a0c8446e2b3

See more details on using hashes here.

File details

Details for the file sendly-3.37.1-py3-none-any.whl.

File metadata

  • Download URL: sendly-3.37.1-py3-none-any.whl
  • Upload date:
  • Size: 79.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.11.15

File hashes

Hashes for sendly-3.37.1-py3-none-any.whl
Algorithm Hash digest
SHA256 63945c026b0a2511a083f1e0620d945c89ef0c99a273a284b7cf53be8fd028f8
MD5 53f98975bf92ff7f11bc657db463712b
BLAKE2b-256 b6afef922a90877c6a1fca65d40980f38e52bb3fd61de4ad93a19298da034f0e

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page