Official .NET SDK for the Sendly SMS API.
- .NET 8.0+
# .NET CLI
dotnet add package Sendly
# Package Manager Console
Install-Package Sendly
# PackageReference (add to .csproj)
<PackageReference Include="Sendly" Version="3.36.0" />using Sendly;
using var client = new SendlyClient("sk_live_v1_your_api_key");
// Send an SMS
var message = await client.Messages.SendAsync(
"+15551234567",
"Hello from Sendly!"
);
Console.WriteLine(message.Id); // "msg_abc123"
Console.WriteLine(message.Status); // "queued"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
| 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.
using var client = new SendlyClient("sk_live_v1_xxx", new SendlyClientOptions
{
BaseUrl = "https://sendly.live/api/v1",
Timeout = TimeSpan.FromSeconds(60),
MaxRetries = 5
});// Marketing message (default)
var message = await client.Messages.SendAsync("+15551234567", "Check out our new features!");
// Transactional message (bypasses quiet hours)
var message = await client.Messages.SendAsync(new SendMessageRequest(
"+15551234567",
"Your verification code is: 123456"
) { MessageType = "transactional" });
// With custom metadata (max 4KB)
var message = await client.Messages.SendAsync(new SendMessageRequest(
"+15551234567",
"Your order #12345 has shipped!"
) {
Metadata = new Dictionary<string, object>
{
{ "order_id", "12345" },
{ "customer_id", "cust_abc" }
}
});
// Send from one of your owned numbers (or an alphanumeric sender ID).
// Omit From to use your default sender.
var message = await client.Messages.SendAsync(new SendMessageRequest(
"+15551234567",
"Hello from our team!"
) { From = "+447111111111" });
Console.WriteLine(message.Id);
Console.WriteLine(message.Status);
Console.WriteLine(message.CreditsUsed);// Basic listing
var messages = await client.Messages.ListAsync();
foreach (var msg in messages)
{
Console.WriteLine(msg.To);
}
// With options
var messages = await client.Messages.ListAsync(new ListMessagesOptions
{
Status = "delivered",
To = "+15551234567",
Limit = 50,
Offset = 0
});
// Pagination info
Console.WriteLine(messages.Total);
Console.WriteLine(messages.HasMore);var message = await client.Messages.GetAsync("msg_abc123");
Console.WriteLine(message.To);
Console.WriteLine(message.Text);
Console.WriteLine(message.Status);
Console.WriteLine(message.DeliveredAt);// Schedule a message for future delivery
var scheduled = await client.Messages.ScheduleAsync(new ScheduleMessageRequest(
"+15551234567",
"Your appointment is tomorrow!",
"2025-01-15T10:00:00Z"
));
Console.WriteLine(scheduled.Id);
Console.WriteLine(scheduled.ScheduledAt);
// List scheduled messages
var result = await client.Messages.ListScheduledAsync();
foreach (var msg in result)
{
Console.WriteLine($"{msg.Id}: {msg.ScheduledAt}");
}
// Get a specific scheduled message
var msg = await client.Messages.GetScheduledAsync("sched_xxx");
// Cancel a scheduled message (refunds credits)
var cancel = await client.Messages.CancelScheduledAsync("sched_xxx");
Console.WriteLine($"Refunded: {cancel.CreditsRefunded} credits");// Send multiple messages in one API call (up to 1000)
var batch = await client.Messages.SendBatchAsync(new SendBatchRequest()
.AddMessage("+15551234567", "Hello User 1!")
.AddMessage("+15559876543", "Hello User 2!")
.AddMessage("+15551112222", "Hello User 3!")
);
Console.WriteLine(batch.BatchId);
Console.WriteLine($"Queued: {batch.Queued}");
Console.WriteLine($"Failed: {batch.Failed}");
Console.WriteLine($"Credits used: {batch.CreditsUsed}");
// Get batch status
var status = await client.Messages.GetBatchAsync("batch_xxx");
// List all batches
var batches = await client.Messages.ListBatchesAsync();
// Preview batch (dry run) - validates without sending
var preview = await client.Messages.PreviewBatchAsync(new SendBatchRequest()
.AddMessage("+15551234567", "Hello User 1!")
.AddMessage("+447700900123", "Hello UK!")
);
Console.WriteLine($"Credits needed: {preview.CreditsNeeded}");
Console.WriteLine($"Will send: {preview.WillSend}, Blocked: {preview.Blocked}");// Auto-pagination with IAsyncEnumerable
await foreach (var message in client.Messages.GetAllAsync())
{
Console.WriteLine($"{message.Id}: {message.To}");
}
// With options
await foreach (var message in client.Messages.GetAllAsync(new ListMessagesOptions
{
Status = "delivered"
}))
{
Console.WriteLine($"Delivered: {message.Id}");
}// Send a group MMS to 2-8 US/Canada recipients. Everyone sees the others and
// replies fan out to the group. Omit From to use your default sender.
var group = await client.Messages.SendGroupAsync(new SendGroupMessageRequest(
new[] { "+14155551234", "+14155555678" },
"Hey team - quick sync at noon?"
));
Console.WriteLine(group.Id); // "msg_abc123"
Console.WriteLine(group.GroupMessageId); // "grp_xxx" (on live sends)
Console.WriteLine(group.Status); // "sent" or "delivered"// Rewrite a draft into a single polished SMS segment (<=160 chars).
var result = await client.Messages.EnhanceAsync(new EnhanceMessageRequest(
text: "hey come check out our sale this weekend",
messageType: "marketing"
));
Console.WriteLine(result.Enhanced); // polished rewrite
Console.WriteLine(result.Explanation); // what changed and whyReusable SMS templates with {{variables}}, published for use with the Verify
API. (Distinct from client.Templates, which manages Verify OTP templates.)
// List presets + custom templates
var listing = await client.MessageTemplates.ListAsync();
// Create a draft, then publish
var template = await client.MessageTemplates.CreateAsync(new CreateMessageTemplateRequest
{
Name = "Password Reset",
Text = "{{app_name}}: your reset code is {{code}}. Valid for 10 minutes."
});
await client.MessageTemplates.PublishAsync(template.Id);
// Preview with sample values
var preview = await client.MessageTemplates.PreviewAsync(template.Id, new Dictionary<string, string>
{
["app_name"] = "MyApp",
["code"] = "123456"
});
Console.WriteLine(preview.PreviewText);
// Clone (including from a preset), update, delete
var clone = await client.MessageTemplates.CloneAsync("tpl_preset_otp", "My Custom OTP");
await client.MessageTemplates.DeleteAsync(clone.Id);Mint branded, owned-domain short links (better carrier deliverability than public shorteners) with click analytics and a per-link kill switch.
Note: URL shortening is gated behind the founder-only
url_shortenerflag and is not yet publicly stable. Calls raiseNotFoundException(not_found) until the flag is on for your account.
// Shorten a URL
var link = await client.Links.CreateAsync("https://example.com/spring-sale");
Console.WriteLine(link.ShortUrl); // "https://sendly.live/l/Ab3xY7"
// List your links with click counts
var links = await client.Links.ListAsync(new ListShortLinksOptions { Limit = 20 });
foreach (var l in links.Links)
{
Console.WriteLine($"{l.ShortUrl} -> {l.DestinationUrl} ({l.ClickCount} clicks)");
}
// Kill / re-enable a link
await client.Links.DisableAsync(link.Code);
await client.Links.EnableAsync(link.Code);// Browse coverage and search for an available number
var countries = await client.Numbers.ListCountriesAsync();
var available = await client.Numbers.ListAvailableAsync(new ListAvailableNumbersOptions
{
Country = "GB",
Type = "mobile"
});
// List the numbers you own
var owned = await client.Numbers.ListAsync();
foreach (var n in owned.Numbers)
{
Console.WriteLine($"{n.PhoneNumber} — {n.Status}");
}
// Get one by id (includes IsDefault)
var number = await client.Numbers.GetAsync("num_xxx");
Console.WriteLine($"{number.PhoneNumber} — default: {number.IsDefault}");
// Make it the workspace default sender (must be active)
var updated = await client.Numbers.UpdateAsync("num_xxx", new UpdateNumberRequest { IsDefault = true });
// (or the convenience wrapper)
await client.Numbers.SetDefaultAsync("num_xxx");
// Cancel a scheduled release ("keep this number")
await client.Numbers.UpdateAsync("num_xxx", new UpdateNumberRequest { PendingCancellation = false });
await client.Numbers.KeepAsync("num_xxx"); // convenience wrapper
// Release a number. A live paid purchase is cancelled at period end.
var release = await client.Numbers.ReleaseAsync("num_xxx");
if (release.Scheduled == true)
Console.WriteLine($"Releases at {release.ScheduledReleaseAt}");
else
Console.WriteLine("Released");Connect a number you own to WhatsApp ($19 one-time setup, no monthly fee),
create Meta-reviewed message templates, and send with
client.Messages.SendAsync(new SendWhatsAppMessageRequest(...)). Free-form
text and media only deliver inside an open 24-hour customer-service window
(the recipient messaged you in the last 24h); an approved template works
anytime. WhatsApp requires a live API key.
// 1. Connect a number. The connect URL must be opened by a human — they log
// in with Facebook in a browser to link their WhatsApp Business Account.
var signup = await client.WhatsApp.Signup.CreateAsync("+15559876543");
Console.WriteLine($"Have your user open: {signup.ConnectUrl}");
// 2. Poll until active
var status = await client.WhatsApp.Signup.GetAsync(signup.Id);
Console.WriteLine(status.Status); // "initiated" -> "registering" -> "active"
// List your connected senders
var senders = await client.WhatsApp.Senders.ListAsync();
foreach (var s in senders.Senders)
{
Console.WriteLine($"{s.PhoneNumber} ({s.DisplayName ?? "no name yet"}) — {s.Status}");
}
// 3. Create a template (Meta reviews it, usually 24-48h)
var template = await client.WhatsApp.Templates.CreateAsync(new CreateWhatsAppTemplateRequest
{
Sender = "+15559876543",
Name = "order_shipped",
Language = "en_US",
Category = "UTILITY",
Body = "Hi {{1}}, your order {{2}} has shipped!",
Examples = new() { ["1"] = "Sam", ["2"] = "#4821" }
});
Console.WriteLine(template.Status); // "PENDING"
// List templates; edit a rejected one and resubmit (template names are locked
// for ~30 days after deletion, so editing is the recovery path)
var templates = await client.WhatsApp.Templates.ListAsync();
await client.WhatsApp.Templates.UpdateAsync(template.Id, new UpdateWhatsAppTemplateRequest
{
Body = "Hi {{1}}, your order {{2}} is on its way!",
Examples = new() { ["1"] = "Sam", ["2"] = "#4821" }
});
await client.WhatsApp.Templates.DeleteAsync("wat_xxx");
// 4. Check the 24-hour window, then send
var window = await client.WhatsApp.WindowAsync("+15559876543", "+15551234567");
// Free-form text inside an open window
var message = await client.Messages.SendAsync(new SendWhatsAppMessageRequest(
"+15551234567",
"+15559876543",
text: "Your table is ready!"
));
// Media with a caption (window-bound; WhatsApp accepts exactly one attachment)
await client.Messages.SendAsync(new SendWhatsAppMessageRequest(
"+15551234567",
"+15559876543",
text: "Here's your receipt",
mediaUrls: new() { "https://example.com/receipt.pdf" }
));
// Template send — works regardless of the window
await client.Messages.SendAsync(new SendWhatsAppMessageRequest(
"+15551234567",
"+15559876543",
template: new WhatsAppTemplateSendParams
{
Name = "order_shipped",
Language = "en_US",
Variables = new() { ["1"] = "Sam", ["2"] = "#4821" }
}
));
Console.WriteLine(message.WhatsApp.Kind); // "text", "media", or "template"
Console.WriteLine(message.CreditsUsed); // priced by country + category
// 5. Read and edit a sender's business profile — the contact card recipients
// see. Supply only the fields to change; omitted fields keep their value.
var profile = await client.WhatsApp.Senders.GetProfileAsync("+15559876543");
Console.WriteLine($"{profile.DisplayName} — {profile.About}");
await client.WhatsApp.Senders.UpdateProfileAsync("+15559876543",
new UpdateWhatsAppSenderProfileRequest
{
About = "Fast delivery, friendly service", // max 139 chars
Description = "Acme sells everything.", // max 512 chars
Website = "https://example.com"
});Send branded rich messaging — cards and suggestion chips — with
client.Messages.SendAsync(new SendRcsMessageRequest(...)). Plain-text RCS
sends fall back to SMS automatically for recipients whose device can't receive
RCS. RCS is gated behind the rcs_channel rollout flag (default-dark) and
requires a live API key.
// 1. Find your agent — the brand identity your messages are sent as. Contact
// support to register one; "testing" reaches invited test numbers only,
// "approved" reaches everyone.
var agents = await client.Rcs.Agents.ListAsync();
foreach (var agent in agents.Agents)
{
Console.WriteLine($"{agent.Name} ({agent.Status}, sendable={agent.Sendable})");
}
// 2. Optional pre-flight: can this recipient receive RCS?
var capability = await client.Rcs.CapabilityAsync("+15551234567");
Console.WriteLine(capability.Capable); // false -> text falls back to SMS
Console.WriteLine(string.Join(", ", capability.Features));
// 3. Send text, optionally with suggestion chips. A reply chip's tap comes
// back as an inbound message carrying your postbackData; an action chip
// opens a URL.
var message = await client.Messages.SendAsync(new SendRcsMessageRequest(
"+15551234567",
text: "Your order #4821 has shipped!",
suggestions: new()
{
RcsSuggestion.CreateReply("Thanks", "thanks"),
RcsSuggestion.CreateAction("Track", "track", "https://example.com/track/4821")
}
));
// The response tells you which leg delivered
if (message.FellBackToSms)
{
// Not RCS-capable: sent and billed as SMS, chips dropped
Console.WriteLine(message.Channel); // "sms"
Console.WriteLine(message.Rcs.RequestedChannel); // "rcs"
Console.WriteLine(message.Rcs.SuggestionsDropped); // True
}
else
{
Console.WriteLine(message.Channel); // "rcs"
Console.WriteLine(message.Rcs.Kind); // "text"
Console.WriteLine(message.Rcs.AgentName); // "Acme Inc"
}
// 4. Send a rich card. Cards have no SMS form — a card to a non-RCS recipient
// fails with rcs_not_supported_for_recipient rather than falling back.
await client.Messages.SendAsync(new SendRcsMessageRequest(
"+15551234567",
card: new RcsCard
{
Title = "Order #4821 shipped",
Description = "Arriving Thursday",
MediaUrl = "https://example.com/package.jpg", // public JPEG, PNG, or GIF
Orientation = "vertical", // or "horizontal"
Suggestions = new()
{
RcsSuggestion.CreateAction("Track", "track", "https://example.com/track/4821")
}
}
));
// Opt out of the SMS fallback to get a 422 instead of an SMS charge
await client.Messages.SendAsync(new SendRcsMessageRequest(
"+15551234567",
text: "RCS only, please",
fallbackToSms: false
));
// Pass agentId when your workspace has more than one agent (otherwise the
// send fails with rcs_agent_ambiguous)
await client.Messages.SendAsync(new SendRcsMessageRequest(
"+15551234567",
text: "Your order #4821 has shipped!",
agentId: "rag_abc123"
));// Create a webhook endpoint
var webhook = await client.Webhooks.CreateAsync(new CreateWebhookOptions
{
Url = "https://example.com/webhooks/sendly",
Events = new List<string> { "message.delivered", "message.failed" }
});
Console.WriteLine(webhook.Id);
Console.WriteLine(webhook.Secret); // Store securely!
// List all webhooks
var webhooks = await client.Webhooks.ListAsync();
// Get a specific webhook
var wh = await client.Webhooks.GetAsync("whk_xxx");
// Update a webhook
await client.Webhooks.UpdateAsync("whk_xxx", new UpdateWebhookOptions
{
Url = "https://new-endpoint.example.com/webhook",
Events = new List<string> { "message.delivered", "message.failed", "message.sent" }
});
// Test a webhook
var result = await client.Webhooks.TestAsync("whk_xxx");
// Rotate webhook secret
var rotation = await client.Webhooks.RotateSecretAsync("whk_xxx");
// Delete a webhook
await client.Webhooks.DeleteAsync("whk_xxx");
// List available webhook event types
var eventTypes = await client.Webhooks.ListEventTypesAsync();
foreach (var eventType in eventTypes)
{
Console.WriteLine($"Event: {eventType}");
}// Get account information
var account = await client.Account.GetAsync();
Console.WriteLine(account.Email);
// Check credit balance
var credits = await client.Account.GetCreditsAsync();
Console.WriteLine($"Available: {credits.AvailableBalance} credits");
Console.WriteLine($"Reserved: {credits.ReservedBalance} credits");
Console.WriteLine($"Total: {credits.Balance} credits");
// View credit transaction history
var transactions = await client.Account.ListTransactionsAsync();
foreach (var tx in transactions)
{
Console.WriteLine($"{tx.Type}: {tx.Amount} credits - {tx.Description}");
}
// List API keys
var keys = await client.Account.ListApiKeysAsync();
foreach (var key in keys)
{
Console.WriteLine($"{key.Name}: {key.Prefix}*** ({key.Type})");
}
// Get a specific API key
var key = await client.Account.GetApiKeyAsync("key_xxx");
// Get API key usage stats
var usage = await client.Account.GetApiKeyUsageAsync("key_xxx");
Console.WriteLine($"Messages sent: {usage.MessagesSent}");
// Create a new API key
var newKey = await client.Account.CreateApiKeyAsync(new CreateApiKeyOptions
{
Name = "Production Key"
});
Console.WriteLine($"New key: {newKey.Key}"); // Only shown once!
// Revoke an API key
await client.Account.RevokeApiKeyAsync("key_xxx");
// Rotate a key. Mints a replacement and keeps the old one working for a grace
// period (24-168 hours, default 24) so you can roll the new key out first.
var rotation = await client.Account.RotateApiKeyAsync("key_xxx", gracePeriodHours: 48);
Console.WriteLine(rotation.NewKey.Key); // full raw sk_ value — shown once!
Console.WriteLine(rotation.NewKey.Warning);
Console.WriteLine(rotation.OldKey.ExpiresAt); // when the old key stops working
Console.WriteLine(rotation.Message);using Sendly.Exceptions;
try
{
var message = await client.Messages.SendAsync("+15551234567", "Hello!");
}
catch (AuthenticationException e)
{
// Invalid API key
}
catch (RateLimitException e)
{
// Rate limit exceeded
Console.WriteLine($"Retry after: {e.RetryAfter?.TotalSeconds} seconds");
}
catch (InsufficientCreditsException e)
{
// Add more credits
}
catch (ValidationException e)
{
// Invalid request
}
catch (NotFoundException e)
{
// Resource not found
}
catch (NetworkException e)
{
// Network error
}
catch (SendlyException e)
{
// Other error
Console.WriteLine(e.Message);
Console.WriteLine(e.ErrorCode);
Console.WriteLine(e.StatusCode);
}message.Id // Unique identifier
message.To // Recipient phone number
message.Text // Message content
message.Status // queued, sending, sent, delivered, failed
message.CreditsUsed // Credits consumed
message.CreatedAt // DateTime
message.UpdatedAt // DateTime
message.DeliveredAt // DateTime? (nullable)
message.ErrorCode // string? (nullable)
message.ErrorMessage // string? (nullable)
// Helper properties
message.IsDelivered // bool
message.IsFailed // bool
message.IsPending // bool| Status | Description |
|---|---|
queued |
Message is queued for delivery |
sending |
Message is being sent |
sent |
Message was sent to carrier |
delivered |
Message was delivered |
failed |
Message delivery failed |
| Tier | Countries | Credits per SMS |
|---|---|---|
| Domestic | US, CA | 2 |
| Tier 1 | GB, PL, IN, etc. | 8 |
| Tier 2 | FR, JP, AU, etc. | 12 |
| Tier 3 | DE, IT, MX, etc. | 16 |
Use test API keys (sk_test_v1_xxx) with these 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 |
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_*).
Create a fully configured workspace in a single call:
var client = new SendlyClient("sk_live_v1_master_YOUR_KEY");
var result = await client.Enterprise.ProvisionAsync(new ProvisionWorkspaceOptions
{
Name = "Acme Insurance - Austin",
SourceWorkspaceId = "ws_verified",
CreditAmount = 5000,
CreditSourceWorkspaceId = "SOURCE_WORKSPACE_ID",
KeyName = "Production",
KeyType = "live",
GenerateOptInPage = true
});
Console.WriteLine(result.Workspace.Id);
Console.WriteLine(result.Key?.Key);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 = new VerificationData{...} |
Full business details, new number + carrier approval |
var ws = await client.Enterprise.Workspaces.CreateAsync("Acme Insurance");
var list = await client.Enterprise.Workspaces.ListAsync();
var detail = await client.Enterprise.Workspaces.GetAsync("ws_xxx");
await client.Enterprise.Workspaces.DeleteAsync("ws_xxx");await client.Enterprise.Workspaces.TransferCreditsAsync("ws_dest", "ws_source", 5000);
var key = await client.Enterprise.Workspaces.CreateKeyAsync("ws_xxx", new CreateWorkspaceKeyOptions
{
Name = "Production",
Type = "live"
});
Console.WriteLine(key.Key);
await client.Enterprise.Workspaces.RevokeKeyAsync("ws_xxx", "key_abc");await client.Enterprise.Webhooks.SetAsync("https://yourapp.com/webhooks");
var overview = await client.Enterprise.Analytics.OverviewAsync();
var messages = await client.Enterprise.Analytics.MessagesAsync(new EnterpriseAnalyticsOptions { Period = "30d" });
var delivery = await client.Enterprise.Analytics.DeliveryAsync();Full enterprise docs: sendly.live/docs/enterprise
MIT