Rust SDK
sent is the Sent REST API from Rust: SMS, WhatsApp, RCS, and voice through one API, with routing and compliance handled by Sent. Every operation is a typed async method, every response a struct, and the resources carry the same names as in the other Sent SDKs, so contacts.list and webhooks.rotate_secret read here as they do in Python or Go.
The crate is in Preview. It is generated from Sent's published OpenAPI document and every release passes two checks: the API's own response examples decode into the crate's types, and every operation runs against a mock of the API. The crate's own API may still change between minor versions until its 1.0.
Requirements
Rust 1.85 or later, on Tokio.
Installation
cargo add sent tokio --features tokio/fullThe client is reqwest with rustls, so nothing links against the system's TLS. The types are serde.
Quick Start
Create the client
use sent::Client;
// Reads SENT_API_KEY
let client = Client::from_env()?;Client::new("sk_live_...") takes the key directly. Use sk_test_... keys for the sandbox.
Send your first message
use sent::models::SendMessageV3Request;
use sent::Client;
#[tokio::main]
async fn main() -> sent::Result<()> {
let client = Client::from_env()?;
let request: SendMessageV3Request = serde_json::from_value(serde_json::json!({
"to": ["+14155551234"],
"channel": ["sms"],
"template": {
"id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
"parameters": { "name": "Ada", "order_id": "12345" }
}
}))?;
let sent = client.messages().send(&request).await?;
for recipient in sent.recipients.unwrap_or_default() {
println!("{}: {:?}", recipient.to.unwrap_or_default(), recipient.message_id);
}
Ok(())
}A request type is a plain struct with Default, so SendMessageV3Request { to: vec!["+14155551234".into()], ..Default::default() } builds the same request without JSON.
Authentication
The client sends the key as x-api-key on every request. Client::from_env() reads SENT_API_KEY, and falls back to SENT_DM_API_KEY where that older name is still set. For more than a key, use the builder:
use std::time::Duration;
use sent::Client;
let client = Client::builder("sk_live_...")
.profile_id("3fa85f64-5717-4562-b3fc-2c963f66afa6") // x-profile-id: scope every request to a child profile
.timeout(Duration::from_secs(30))
.max_retries(3)
.build()?;Keep the key on the server. Never ship it in a client app.
Requests and retries
Every non-GET request carries an Idempotency-Key the client generates, and keeps the same key when it sends the request again, so a retried send is performed once. The client retries on 429 and on 5xx, twice by default, with a backoff that honours Retry-After. max_retries(0) turns that off.
Request types that support the sandbox carry sandbox: Option<bool>. Set it to Some(true) to simulate an operation without side effects.
Error handling
A refused request is Error::Api, with what Sent said:
use sent::Error;
match client.messages().send(&request).await {
Ok(sent) => println!("{:?}", sent.status),
Err(Error::Api(failure)) => {
eprintln!("{} {}: {}", failure.status, failure.code, failure.message);
if let Some(details) = &failure.details {
for (field, problems) in details {
eprintln!(" {field}: {}", problems.join(", "));
}
}
if let Some(id) = &failure.request_id {
eprintln!(" request {id}, for support");
}
}
Err(Error::Transport(error)) => eprintln!("could not reach Sent: {error}"),
Err(error) => eprintln!("{error}"),
}Error::Transport is a request that never got an answer. Error::Decode is a 2xx whose body is not what the API documents. Error::Config is a client that cannot send anything, such as an empty key. error.api() returns the failure when the error is one, for code that only cares about that case.
Pagination
List methods take a params struct and return a Page with the items and what the API says about the pages around it. The list_all twin walks every page:
use sent::resources::contacts::ContactsListParams;
let params = ContactsListParams { search: Some("ada".into()), page_size: Some(50), ..Default::default() };
let page = client.contacts().list(¶ms).await?;
println!("{} of {:?} contacts, more: {}", page.items.len(), page.pagination.total_count, page.has_more());
let every_contact = client.contacts().list_all(¶ms).await?;Pages are one-indexed. Leave page unset for the first.
Contacts
use sent::models::CreateContactRequest;
let contact = client.contacts().create(&CreateContactRequest { phone_number: "+14155551234".into(), ..Default::default() }).await?;
let again = client.contacts().retrieve(&contact.id.clone().unwrap_or_default()).await?;Messages
let message = client.messages().retrieve_status("8ba7b830-9dad-11d1-80b4-00c04fd430c8").await?;
let activities = client.messages().retrieve_activities("8ba7b830-9dad-11d1-80b4-00c04fd430c8").await?;Webhooks
Subscriptions are managed through client.webhooks(): create, list, retrieve, update, delete, rotate_secret, test, toggle_status, and list_event_types. The events Sent POSTs to your endpoint have types too, under sent::models: MessageEvent, InboundMessageEvent, TemplateEvent, ChannelEvent, ContactEvent and CallEvent, each with a typed payload. Decode the body your handler receives with serde_json::from_slice::<MessageEvent>(&body).
Compatibility
Sent adds fields, enum values, and resources without a major version. The crate is built for that: structs ignore fields they do not know, and every enum has an Other(String) variant, so a value Sent adds later decodes rather than failing the whole response.
Source & Issues
The crate is rendered from the API document by Sent's SDK pipeline. The source lives at sentdm/sent-dm-rust, where issues and pull requests are welcome. Generated files are regenerated on every API change. The client, errors, and pagination are written by hand.
Getting Help
- API reference
- Error catalog
- Support: support@sent.dm
Sending messages from Sinatra with the Sent Ruby SDK
Wire the Sent Ruby SDK into a Sinatra app: install, configure the client, send a template message from a route, verify webhooks, and test with sandbox mode.
Voice SDK
Official browser SDK for Sent Voice. Place and receive calls in a web app with a voice token your backend mints, with React bindings in a separate package.