sendly
Official Python SDK for the Sendly SMS API.
Installation
# pip
pip install sendly
# poetry
poetry add sendly
# pipenv
pipenv install sendly
Requirements
- Python 3.8+
- A Sendly API key (get one here)
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:
-
Business Verification - Complete verification in the Sendly dashboard
- International: Instant approval (just provide Sender ID)
- US/Canada: Requires carrier approval (3-7 business days)
-
Credits - Add credits to your account
- Test keys (
sk_test_*) work without credits (sandbox mode) - Live keys (
sk_live_*) require credits for each message
- Test keys (
-
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'Status: {batch.status.value}')
print(f'Failed: {batch.failed}')
print(f'Credits used: {batch.credits_used}')
# A large batch is accepted as 'processing' with an empty messages list, so
# poll the batch for per-message results. The send response carries no queued,
# delivered, credits_reserved or created_at; get_batch and list_batches do.
status = client.messages.get_batch(batch.batch_id)
print(f'Queued: {status.queued}, delivered: {status.delivered}')
for result in status.messages:
print(f'{result.to}: {result.status}')
# List all batches - same fields as get_batch, minus the per-message results
result = client.messages.list_batches()
for summary in result.data:
print(f'{summary.batch_id}: {summary.queued} queued, {summary.delivered} delivered')
# 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)
Idempotency
POSTs carry an automatically generated Idempotency-Key, reused across the
SDK's own timeout and network-error retries, so a retry of a request that
already reached the API returns the original result instead of sending and
charging again. After a 5xx the generated key is rotated so the retry really
does run again. You do not have to do anything to get this.
Pass your own key when the guarantee needs to outlive the process — a job queue that re-runs after a crash, or your own retry loop:
client.messages.send(
to="+15551234567",
text="Your order has shipped!",
idempotency_key="order-4821-shipped",
)
Reusing a key within 24 hours returns the original response. Reusing it with a
different body raises 422 idempotency_key_mismatch, so derive keys from
something stable in your domain, like an order id. send_batch sends no
automatic key, because the API already deduplicates identical batches by their
contents.
Full details: https://sendly.live/docs/idempotency
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, WebhookSignatureError
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)
except WebhookSignatureError as e:
print(f'Invalid signature: {e}')
return 'Invalid signature', 400
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}')
elif event.type == 'rcs_agent.live':
# A lifecycle event: event.data is None, the payload is on event.object
print(f"Agent {event.object['agent_id']} is live")
return 'OK', 200
Reading the Event Payload
event.object is data.object exactly as it arrived, for every event type.
Keys are verbatim (camelCase stays camelCase), JSON null stays None, and
nothing the payload did not carry is added.
event.data is the message view. It is None for every event that is not a
message.* event, because RCS, WhatsApp, voice (call.*), 10DLC
(brand.* / campaign.* / assignment.*), number.*, port*, contact*,
conversation.*, draft.* and verification.* payloads are not
message-shaped. message.opt_in and message.opt_out are None too — they
share the prefix but carry an opt-out record (phone_number, keyword,
from_number, timestamp), not a message.
from dataclasses import dataclass
from typing import Optional
from sendly import Webhooks, is_message_event
event = Webhooks.parse_event(payload, signature, WEBHOOK_SECRET, timestamp=timestamp)
if event.data is not None:
# message.* event
print(event.data.id, event.data.to, event.data.credits_used)
else:
# lifecycle event: read the raw object
print(event.object)
# is_message_event() answers the same question from an event type alone, before
# you have an event in hand - filtering a subscription, routing a queue
is_message_event('message.delivered') # True
is_message_event('rcs_agent.live') # False
is_message_event('message.opt_out') # False - an opt-out record, not a message
# A contact flagged by a send failure: `id` is the contact, `message_id` is
# the message that failed. They are never swapped.
if event.type == 'contact.auto_flagged':
contact_id = event.object['id']
failed_message_id = event.object['message_id']
# In-app calls legitimately have no numbers; null stays None, never ''
if event.type == 'call.started':
# from/to are null for in-app (browser) calls and carry E.164 numbers
# for PSTN legs, so treat them as optional rather than assuming either.
caller = event.object.get('from')
# Or decode into a shape of your own (dataclass, pydantic model, or dict).
# Only keys the payload actually carried are passed through, so a field it did
# not send keeps its default instead of being invented. A trailing underscore
# maps a reserved word: `from_` reads `from`.
@dataclass
class AgentLive:
agent_id: Optional[str] = None
name: Optional[str] = None
stage: Optional[str] = None
if event.type == 'rcs_agent.live':
agent = event.object_as(AgentLive)
print(agent.agent_id, agent.stage)
raw = event.object_as() # plain dict copy
same = event.raw_object # alias for event.object
verification.* payloads have a ready-made shape — pass WebhookVerificationData to
event.object_as() rather than writing your own.
Event types live in one place, sendly.types.WebhookEventType; sendly.WebhookEventType
re-exports that enum, and sendly.webhooks.WEBHOOK_EVENT_TYPES is the tuple of its values.
An event type this SDK version has not heard of is still delivered rather than rejected:
event.type keeps the raw string and event.object still carries the payload.
message.queued and message.undelivered are not in that enum. The API has never emitted
either one and rejects both with a 400 on subscribe, so 4.0.0 removed them. 'queued' and
'undelivered' remain valid message statuses on event.data.status.
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)
Connect a number you own to WhatsApp ($19 one-time, no monthly fee), create
Meta-reviewed message templates, and send on the whatsapp channel. Connecting
always ends with a human step: hand the connect_url to your user - they open
it in a browser and log in with Facebook to link their WhatsApp Business
Account. Free-form text and media only deliver inside an open 24-hour window
(the recipient messaged you in the last 24h); an approved template works
anytime. Requires a live API key.
# 1. Connect a number (a person must finish the connect URL in a browser)
signup = client.whatsapp.signup.create('+15559876543')
print(f'Open {signup.connect_url} and log in with Facebook')
# ...poll client.whatsapp.signup.get(signup.id) until signup.status == 'active'
# (or 'failed', with signup.failure_reasons explaining why)
# 2. Create a template (Meta reviews it, usually 24-48h)
template = client.whatsapp.templates.create(
sender='+15559876543',
name='order_shipped',
language='en_US',
category='UTILITY',
body='Hi {{1}}, your order {{2}} has shipped!',
examples={'1': 'Sam', '2': '#4821'},
)
# ...poll client.whatsapp.templates.list() until its status == 'APPROVED'
# (a rejected template should be edited with templates.update() and resubmitted -
# deleting locks its name for ~30 days)
# 3. Send - free-form inside an open 24h window, template anytime
window = client.whatsapp.window(from_='+15559876543', to='+15551234567')
if window.open:
message = client.messages.send(
channel='whatsapp',
to='+15551234567',
from_='+15559876543',
text='Your table is ready!',
)
else:
message = client.messages.send(
channel='whatsapp',
to='+15551234567',
from_='+15559876543',
template={'name': 'order_shipped', 'language': 'en_US',
'variables': {'1': 'Sam', '2': '#4821'}},
)
print(message.whatsapp.kind) # 'text' or 'template'
# Media with a caption (window-bound, one attachment per message)
client.messages.send(
channel='whatsapp',
to='+15551234567',
from_='+15559876543',
text='Here is the menu',
media_urls=['https://example.com/menu.jpg'],
)
# List your connected senders
for sender in client.whatsapp.senders.list().senders:
print(f'{sender.phone_number} ({sender.display_name}) - {sender.status}')
# Read and update a sender's business profile (what recipients see)
profile = client.whatsapp.senders.get_profile('+15559876543')
print(profile.display_name, profile.about)
client.whatsapp.senders.update_profile(
'+15559876543',
about='Fresh roasts daily', # max 139 chars
description='Small-batch coffee, roasted in-house every morning.', # max 512
website='https://acme.example.com',
)
RCS
Send branded, verified-sender messages on Android: rich cards, suggestion chips, and read receipts. Sending as your brand requires an RCS agent (the verified identity recipients see). Registration is self-serve, from the dashboard or from this SDK: you draft a brand and an agent, Sendly reviews them, then they go to the carrier network for verification (see Registering an agent below). Text messages automatically fall back to SMS when the recipient's device or network doesn't support RCS (billed as SMS; suggestion chips are dropped); rich cards have no SMS form and respond 422 instead. Sending requires a live API key.
# Your registered agents ('testing' or 'approved' agents are sendable)
for agent in client.rcs.agents.list().agents:
print(agent.name, agent.status, agent.sendable)
# Optionally pre-flight a recipient (live carrier-backed probe)
check = client.rcs.capability(to='+15551234567')
print(check.capable, check.features)
# Text with suggestion chips - falls back to SMS for non-RCS recipients
message = client.messages.send(
channel='rcs',
to='+15551234567',
text='Your table is ready!',
suggestions=[
{'reply': {'text': 'On my way', 'postbackData': 'omw'}},
{'action': {'text': 'View menu', 'postbackData': 'menu',
'url': 'https://example.com/menu'}},
],
)
# The response tells you which leg delivered
if message.channel == 'rcs':
print(message.rcs.agent_name) # delivered over RCS
else:
print(message.fell_back_to) # 'sms'
print(message.rcs.suggestions_dropped) # True - chips have no SMS form
# Rich card (RCS-capable recipients only - no SMS form)
client.messages.send(
channel='rcs',
to='+15551234567',
card={
'title': 'Your order has shipped',
'description': 'Arriving Thursday',
'mediaUrl': 'https://example.com/package.jpg', # public JPEG/PNG/GIF
'orientation': 'vertical',
'suggestions': [
{'action': {'text': 'Track it', 'postbackData': 'track',
'url': 'https://example.com/track'}},
],
},
)
# Opt out of the SMS fallback to get a 422 for non-RCS recipients instead
client.messages.send(
channel='rcs',
to='+15551234567',
text='Your table is ready!',
fallback_to_sms=False,
)
Registering an agent
Registration needs an API key with the rcs:read / rcs:write scopes and is
open to US businesses. It is rolling out gradually: until it is enabled for
your account, every registration call raises SendlyError with code
rcs_not_enabled (HTTP 404). Logo, hero image and call-to-action media must
already be public https:// URLs; uploading assets is a dashboard-only step.
from sendly import RcsCustomerStage
# 1. Prefill from what Sendly already knows (your 10DLC brand or toll-free verification)
dossier = client.rcs.dossier.get()
# 2. Draft the brand (nested address/contact accept dicts or RcsBrandAddress/RcsBrandContact)
brand = client.rcs.brands.create(
display_name='Acme Coffee',
legal_name='Acme Holdings LLC',
legal_entity_type='LIMITED_LIABILITY_COMPANY',
organization_type='PRIVATE_PROFIT',
website_url='https://acme.example',
ein='12-3456789',
address={'line1': '1 Main St', 'city': 'Austin', 'state': 'TX',
'postal_code': '78701', 'country_code': 'US'},
contact={'first_name': 'Sam', 'last_name': 'Lee',
'email': 'sam@acme.example', 'phone_number': '+15551234567'},
)
# 3. Draft the agent under it
agent = client.rcs.agents.create(
brand.id,
display_name='Acme Coffee',
use_case='MULTI_USE',
basics={
'description': 'Order updates and support for Acme customers',
'logo_url': 'https://acme.example/logo.png',
'hero_url': 'https://acme.example/hero.png',
'brand_color': '#5B3A29',
'privacy_policy_url': 'https://acme.example/privacy',
'terms_and_conditions_url': 'https://acme.example/terms',
'website': {'url': 'https://acme.example', 'label': 'Acme'},
},
)
# 4. Submit for review; poll the stage (SendlyError 'rcs_invalid_content'
# lists anything missing in e.field_errors)
client.rcs.agents.submit(agent.id)
registration = client.rcs.registration.get()
print(registration.stage) # 'in_review' -> 'brand_verification' -> 'agent_review' -> 'testing'
# 5. Once in testing: invite devices, describe the campaign, then request launch
if registration.stage == RcsCustomerStage.TESTING:
client.rcs.agents.set_test_devices(agent.id, [
'+15125550142',
{'phone_number': '+15125550177', 'label': 'Sam'},
])
client.rcs.agents.update(
agent.id,
campaign={
'company_overview': 'Acme Coffee runs 12 cafes and an online store.',
'agent_overview': 'Order updates and support replies',
'interactions': [{'interaction_type': 'TRANSACTIONAL_UPDATES',
'description': 'Shipping and delivery updates'}],
'message_examples': ['Acme Coffee: order #123 has shipped. Reply STOP to opt out.',
'Acme Coffee: your table is ready!',
'Acme Coffee: reply HELP for help.'],
'consent_settings': {
'opt_in_methods': [{'method_type': 'WEBSITE',
'description': 'Checkout checkbox'}],
'call_to_action': 'Get order updates by message',
'call_to_action_url': 'https://acme.example/checkout',
'call_to_action_media_url': 'https://acme.example/rcs/opt-in.png',
'double_opt_in': False,
'opt_in_message': 'Acme Coffee: you are subscribed. Reply HELP for help, STOP to opt out.',
'help_response': 'Acme Coffee: email help@acme.example for help.',
'opt_out_response': 'You are unsubscribed.',
},
},
)
client.rcs.agents.request_launch(
agent.id, test_url='https://acme.example/rcs-test-recording'
)
# stage moves through 'launch_review' and 'launching' to 'live'
Writes accept an idempotency_key like the other write methods; submit() and
request_launch() replays return the original response without notifying the
reviewers again.
Voice Calls
Place phone calls that one of your AI agents handles, follow them while they
ring and after they end, end them early, and download recordings. Requires an
API key with the calls:read / calls:write scopes; placing and ending calls
needs a live key. Voice is enabled workspace by workspace: until it is enabled
for yours, every call method raises SendlyError with code voice_not_enabled
(HTTP 404).
Calls are prepaid from your balance per started minute: 2 credits a minute
outbound plus 8 a minute while an AI agent is on the line (10 credits a minute
for an agent-handled outbound call); unanswered calls cost nothing. The number
you call from must be voice-enabled, with an emergency address registered (see
Configure voice); client.voice.numbers.list() shows each
number's voice_enabled, voice_mode and emergency_address. Destinations
are US and Canada.
# Pick a voice-enabled number (optional when exactly one is voice-enabled)
voice_numbers = [n for n in client.voice.numbers.list().data if n.voice_enabled]
# Place a call; the agent speaks first, using the context you pass
call = client.calls.create(
to='+15555550123',
agent_id='3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b',
from_=voice_numbers[0].phone_number,
context='You are calling Jordan to confirm the 3pm appointment on Tuesday.',
metadata={'crmId': 'lead_8812'},
)
print(call.id, call.status) # ... 'ringing'
# Follow it: status moves ringing -> active -> completed (or no_answer, busy, ...)
call = client.calls.get(call.id)
print(call.status, call.duration_secs, call.credits_charged, call.hangup_class)
for line in call.transcript or []: # agent calls only; None on other calls
print(f'[{line.at_ms} ms] {line.speaker}: {line.text}')
# List calls, newest first, with filters and paging
page = client.calls.list(status='completed', direction='outbound', limit=20)
for c in page.data:
print(c.id, c.to, c.duration_secs, c.credits_charged)
if page.pagination.has_more:
page = client.calls.list(status='completed', direction='outbound', limit=20, offset=20)
# End a call early (idempotent: an ended call is returned unchanged)
client.calls.hangup(call.id)
# Fetch the recording; the signed URL is valid for 5 minutes
recording = client.calls.recording(call.id)
if recording.status == 'ready':
print(recording.url, recording.expires_at, recording.content_type) # audio/ogg
Refusals come back as SendlyError with a code that says what to do:
insufficient_credits (raised as InsufficientCreditsError; top up),
e911_required (register an emergency address for the number), lines_busy
(retry with backoff), daily_call_limit (try again tomorrow),
agent_not_found / agent_disabled, from_number_required / no_voice_number,
destination_not_supported, live_key_required. Webhooks call.started,
call.completed and call.recording.ready carry the same call object in
snake_case, including billing and your metadata.
Configure voice
Everything a call depends on can be set up from code: switch voice on for a
number and choose how it answers, register its emergency address, and create
the AI agents that talk. Reads need calls:read; writes need calls:write and
a live key. In a team workspace, changing numbers and managing agents needs an
owner or admin role (forbidden otherwise). These settings change how real
phone calls to your numbers are answered.
from sendly import SendlyError
# The voices an agent can speak with
for v in client.voice.voices.list().data:
print(v.id, v.label, v.language)
# Create an agent (up to 20 per workspace); it gets its own key for texting
agent = client.voice.agents.create(
name='Front desk',
voice='ashley',
greeting='Thanks for calling Acme, how can I help?',
instructions='Answer questions about opening hours and take a message for anything else.',
tools={'send_sms': True},
)
print(agent.id, agent.voice_label, agent.can_send_sms)
# Edit it later; only the fields you pass change
client.voice.agents.update(agent.id, greeting='Hi, you have reached Acme.')
# Numbers with their voice settings; pass either the id or the E.164 number
for n in client.voice.numbers.list().data:
e911 = n.emergency_address.status if n.emergency_address else 'none'
print(n.phone_number, n.voice_mode, n.agent_id, e911)
# Register the emergency address a number needs before it can place calls
# (US and Canadian numbers; the first registration adds 1.50 USD a month)
number = client.voice.numbers.register_emergency_address(
'+15555550188',
street='500 Example Ave',
unit='Suite 2',
city='Austin',
state='TX',
zip='78701',
)
# Let the agent answer the number (voice_mode='ring_dashboard' rings your team)
number = client.voice.numbers.update(
'+15555550188', voice_enabled=True, voice_mode='agent', agent_id=agent.id
)
print(number.voice_mode, number.rate_per_minute.agent) # credits a minute
# Switch voice off (the same as voice_mode='none')
client.voice.numbers.update('+15555550188', voice_enabled=False)
# Deleting an agent revokes its key; refused while a number still points at it
try:
client.voice.agents.delete(agent.id)
except SendlyError as e:
if e.code == 'agent_in_use':
print(e.response.model_extra['numbers'])
Without voice_enabled, voice_mode='ring_dashboard' or 'agent' switches
voice on and voice_mode='none' switches it off; voice_enabled=False always
switches voice off.
Refusals: agent_required (agent mode with no agent), agent_not_found,
agent_disabled (turn the agent on first), agent_limit, agent_in_use,
number_not_found, invalid_voice_mode, voice_attach_failed (HTTP 502,
retry shortly), carrier_refused (HTTP 502, retry shortly unless the message
says the number couldn't be found for emergency registration: contact
support), voice_unavailable (HTTP 503),
e911_not_applicable (only US and Canadian numbers take an emergency
address) and invalid_address; on HTTP 422 the closest valid address is in
e.response.model_extra['suggested']. An agent's tools.transfer_to is
stored, but agents do not transfer calls yet: when a caller asks for a person,
the agent offers to pass a message on and takes their name and number.
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 resourcebase_url- Configured base URL
Methods
is_test_mode()- ReturnsTrueif using a test API keyget_rate_limit_info()- Returns current rate limit infoclose()- 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 (pending → verified/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.
client.rcs
agents.list() -> RcsAgentListResponse
List your RCS agents, newest first, with sendable telling you which can send now.
capability(to, agent_id=None) -> RcsCapability
Check whether a recipient can receive RCS (live key).
registration.get() -> RcsRegistration
The registration at a glance: newest brand, newest agent, its test devices, and the overall stage (RcsCustomerStage).
dossier.get() -> RcsDossier
Business details Sendly already holds for the workspace, to prefill brands.create().
brands.create(display_name=None, legal_name=None, ..., address=None, contact=None, idempotency_key=None) -> RcsBrand
Draft a brand (US businesses only). Required fields are checked at submit, not here.
brands.update(id, ..., idempotency_key=None) -> RcsBrand
Edit a draft brand; only the fields you pass change. Locked while under review (rcs_field_locked).
agents.create(brand_id, display_name=None, use_case=None, basics=None, campaign=None, testing=None, idempotency_key=None) -> RcsAgentDetail
Draft an agent under a brand. Media must be public https:// URLs.
agents.get(id) -> RcsAgentDetail
Fetch one agent with its review_status, customer_stage, review_note and test_devices.
agents.update(id, display_name=None, use_case=None, basics=None, campaign=None, testing=None, idempotency_key=None) -> RcsAgentDetail
Edit an agent; campaign and testing merge section-wise.
agents.set_test_devices(id, devices, idempotency_key=None) -> RcsTestDeviceListResponse
Replace the invited test devices (up to 20); entries are E.164 strings, dicts, or RcsTestDeviceInput.
agents.submit(id, idempotency_key=None) -> RcsAgentDetail
Submit the agent and its brand for review by Sendly, then the carrier network. rcs_invalid_content lists what is missing.
agents.request_launch(id, test_url=None, testing_additional_information=None, idempotency_key=None) -> RcsAgentDetail
Ask for the launch review once the agent is in testing and has been tried on an invited device.
client.calls
create(to, agent_id, from_=None, context=None, metadata=None, idempotency_key=None) -> Call
Place a phone call handled by an AI agent (live key, calls:write). Returns the call while it is ringing; from_ is required when more than one number is voice-enabled.
list(limit=None, offset=None, status=None, direction=None, kind=None, agent_id=None, to=None, from_=None) -> CallListResponse
List calls newest first; pagination.has_more says whether another page exists.
get(id) -> Call
Fetch one call. Agent-handled calls carry transcript; on other calls it is None.
hangup(id, idempotency_key=None) -> Call
End a call (live key, calls:write). ringing becomes cancelled, active becomes completed; an ended call is returned unchanged.
recording(id) -> CallRecording
Where to download the recording. url and expires_at are set only while status is ready; the link is valid for 5 minutes.
client.voice
numbers.list() -> VoiceNumberListResponse
Active numbers in the workspace with voice_enabled, voice_mode, agent_id, emergency_address and rate_per_minute.
numbers.get(number) -> VoiceNumber
One number's voice settings. number is the number's id or its E.164 phone number.
numbers.update(number, voice_enabled=None, voice_mode=None, agent_id=None, idempotency_key=None) -> VoiceNumber
Change how a number answers calls (live key, calls:write). Without voice_enabled, ring_dashboard or agent switches voice on and none switches it off; voice_enabled=False always switches it off. agent needs an enabled agent.
numbers.register_emergency_address(number, street, city, state, zip, unit=None, country=None, idempotency_key=None) -> VoiceNumber
Register the emergency address a number needs before it can place calls (live key, calls:write). US and Canadian numbers only.
agents.list() -> VoiceAgentListResponse
The workspace's AI agents.
agents.create(name, enabled=None, voice=None, language=None, greeting=None, instructions=None, tools=None, idempotency_key=None) -> VoiceAgent
Create an AI agent (live key, calls:write). Up to 20 per workspace.
agents.get(id) -> VoiceAgent
Fetch one agent.
agents.update(id, name=None, enabled=None, voice=None, language=None, greeting=None, instructions=None, tools=None, idempotency_key=None) -> VoiceAgent
Edit an agent; only the fields you pass change, and tools may be partial.
agents.delete(id, idempotency_key=None) -> DeletedVoiceAgent
Delete an agent and revoke its sending key. Raises agent_in_use (HTTP 409) while a number still points at it.
voices.list() -> VoiceListResponse
The voices an agent can speak with; pass a voice's id as voice.
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
Metadata
Release files for sendly 4.2.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 | |
|---|---|---|---|---|
| sendly-4.2.0.tar.gz | 189.1 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sendly-4.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 317.0 kB
Release files / sendly-4.2.0.tar.gz
| Download URL | sendly-4.2.0.tar.gz |
|---|---|
| Size | 189.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8504d32547f7e933d292b773315e98cc69cc5bf669d8b4a943c7def91a8abab5
|
|
BLAKE2b-256 checksum How to use checksums |
2f62b73d1634e1ba75a74bb44ea77a658d71ab4785b6dbcc599ff3f572b9a9f3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.16
|
Release files / sendly-4.2.0-py3-none-any.whl
| Download URL | sendly-4.2.0-py3-none-any.whl |
|---|---|
| Size | 127.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
786ea8e839a4828083994c17d6e2b8e9b26cfc72b4450600c9e7220020247f24
|
|
BLAKE2b-256 checksum How to use checksums |
003b6387e9b626a0561ab4b111c931834a67448692cd6adcdeb893a68f474136
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.16
|
Security for sendly 4.2.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.
No provenance found
None of the files in this release were uploaded with provenance metadata.
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.