iMessage API in Rust — Send Blue Bubbles from Your Rust App (2026)
Rust's speed and safety make it ideal for high-throughput messaging systems. Sendblue provides a REST API that any language — including Rust — can call to send and receive iMessages. This guide covers everything from Cargo setup to async webhook handlers, with working code you can drop into your project today.
Why use iMessage from a Rust application?
iMessage reaches 98% of Apple devices with end-to-end encryption, read receipts, and typing indicators built in. Blue bubbles get 30–45% response rates — far above SMS or email. For Rust developers building backend services, notification systems, or customer engagement platforms, adding iMessage is a matter of making an HTTPS POST request.
Sendblue abstracts the Apple infrastructure: you never need a Mac server, Apple Business Register account, or special hardware. Your Rust binary calls a REST API, and Sendblue delivers the blue bubble. Use cases include:
- Real-time alerts from Rust monitoring services
- Customer onboarding sequences from Actix or Axum web servers
- Two-factor authentication codes via iMessage
- Order confirmation and shipping notifications
Add dependencies to Cargo.toml
You need reqwest with JSON support, tokio for the async runtime, and serde/serde_json for serialization. Add these to your Cargo.toml:
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# For loading env vars from a .env file during development
dotenvy = "0.15"For production deployments, load SENDBLUE_API_KEY_ID and SENDBLUE_API_SECRET_KEY from your secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.) rather than a .env file.
Define request and response types
Model the Sendblue API payload with serde-derived structs. Using Option for optional fields lets you omit them when not needed — serde's skip_serializing_if prevents them from appearing in the JSON body at all:
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize)]
pub struct SendMessageRequest {
pub number: String,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub send_style: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub media_url: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SendMessageResponse {
pub status: Option<String>,
#[serde(rename = "messageHandle")]
pub message_handle: Option<String>,
pub error: Option<String>,
pub number: Option<String>,
pub content: Option<String>,
}Send an iMessage with async/await
Build a reusable SendblueClient struct that holds the reqwest::Client and your credentials. Reusing a single client instance is important — it pools TCP connections and respects keep-alive, which matters at scale:
use reqwest::{Client, header::{HeaderMap, HeaderValue}};
use std::env;
pub struct SendblueClient {
http: Client,
api_key_id: String,
api_secret_key: String,
}
impl SendblueClient {
pub fn new() -> Self {
dotenvy::dotenv().ok();
Self {
http: Client::new(),
api_key_id: env::var("SENDBLUE_API_KEY_ID")
.expect("SENDBLUE_API_KEY_ID not set"),
api_secret_key: env::var("SENDBLUE_API_SECRET_KEY")
.expect("SENDBLUE_API_SECRET_KEY not set"),
}
}
pub async fn send_message(
&self,
req: &SendMessageRequest,
) -> Result<SendMessageResponse, reqwest::Error> {
let mut headers = HeaderMap::new();
headers.insert(
"sb-api-key-id",
HeaderValue::from_str(&self.api_key_id).unwrap(),
);
headers.insert(
"sb-api-secret-key",
HeaderValue::from_str(&self.api_secret_key).unwrap(),
);
let response = self
.http
.post("https://api.sendblue.co/api/send-message")
.headers(headers)
.json(req)
.send()
.await?
.json::<SendMessageResponse>()
.await?;
Ok(response)
}
}
#[tokio::main]
async fn main() {
let client = SendblueClient::new();
let req = SendMessageRequest {
number: "+14155551234".to_string(),
content: "Hey! Just checking in from our Rust backend. 🦀".to_string(),
send_style: None,
media_url: None,
};
match client.send_message(&req).await {
Ok(res) => println!("Sent! Handle: {:?}, Status: {:?}", res.message_handle, res.status),
Err(e) => eprintln!("Error sending message: {}", e),
}
}Handle errors and retries idiomatically
Use a custom error type and implement retry logic with exponential backoff. The tokio::time::sleep approach below is simple and effective for most use cases:
use std::time::Duration;
use tokio::time::sleep;
pub async fn send_with_retry(
client: &SendblueClient,
req: &SendMessageRequest,
max_attempts: u32,
) -> Result<SendMessageResponse, String> {
let mut attempt = 0;
loop {
attempt += 1;
match client.send_message(req).await {
Ok(res) => {
if res.error.is_some() {
return Err(format!("API error: {:?}", res.error));
}
return Ok(res);
}
Err(e) if attempt < max_attempts => {
let backoff = Duration::from_millis(200 * 2u64.pow(attempt - 1));
eprintln!("Attempt {} failed: {}. Retrying in {:?}", attempt, e, backoff);
sleep(backoff).await;
}
Err(e) => return Err(format!("All {} attempts failed: {}", max_attempts, e)),
}
}
}Receive webhook callbacks with Axum
Configure a webhook URL in your Sendblue dashboard. When a message is delivered or a contact replies, Sendblue POSTs a JSON payload to your URL. Use Axum to handle these callbacks. Add axum and tower-http to your Cargo.toml, then define a handler:
[dependencies]
axum = "0.7"
tower-http = { version = "0.5", features = ["trace"] }use axum::{routing::post, Router, Json, http::StatusCode};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct SendblueWebhook {
#[serde(rename = "accountEmail")]
account_email: Option<String>,
content: Option<String>,
#[serde(rename = "isOutbound")]
is_outbound: bool,
status: String,
#[serde(rename = "messageHandle")]
message_handle: String,
#[serde(rename = "fromNumber")]
from_number: String,
number: String,
#[serde(rename = "mediaUrl")]
media_url: Option<String>,
}
async fn handle_webhook(
Json(payload): Json<SendblueWebhook>,
) -> StatusCode {
if payload.is_outbound {
// Delivery status update for a message we sent
println!(
"Message {} status: {}",
payload.message_handle, payload.status
);
} else {
// Inbound reply from a contact
if let Some(content) = &payload.content {
println!("Reply from {}: {}", payload.from_number, content);
// TODO: route to your reply-handling logic
}
}
StatusCode::OK
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/webhook/sendblue", post(handle_webhook));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Webhook server listening on port 3000");
axum::serve(listener, app).await.unwrap();
}During development, expose your local server with ngrok http 3000 and paste the HTTPS URL into the Sendblue dashboard webhook settings.
Send media attachments
To send an image, video, or contact card, add a media_url pointing to a publicly accessible HTTPS URL. The file must be reachable by Sendblue's servers — upload to S3, Cloudflare R2, or any CDN first:
let req = SendMessageRequest {
number: "+14155551234".to_string(),
content: "Check out this product screenshot!".to_string(),
send_style: None,
media_url: Some("https://your-cdn.example.com/screenshot.png".to_string()),
};
let res = client.send_message(&req).await?;Supported formats include JPEG, PNG, GIF, MP4, and .vcf contact cards. Keep images under 5 MB for reliable delivery across all iOS versions.
Frequently asked questions
Does Sendblue work with Rust?
Yes. Sendblue exposes a standard REST API over HTTPS. Any HTTP client in any language — including Rust's reqwest crate — can call it. You POST to https://api.sendblue.co/api/send-message with JSON and your API key headers, and Sendblue handles all the Apple infrastructure on your behalf.
What are the rate limits?
Rate limits depend on your plan. Sandbox accounts are limited to 5 messages per minute for testing. Paid plans support higher throughput. For bulk sends, use Tokio channels or a semaphore to cap concurrent requests: Arc<Semaphore>::new(10) limits you to 10 in-flight requests at a time.
Can I send media (images, files) from Rust?
Yes. Add a media_url field to your JSON payload pointing to a publicly accessible HTTPS URL for an image, video, or .vcf contact card. Sendblue fetches and delivers the media as an iMessage attachment. There is no binary upload endpoint — host your files on S3, Cloudflare R2, or any CDN first.
How do status callbacks work?
Configure a webhook URL in the Sendblue dashboard. After each message is delivered (or fails), Sendblue POSTs a JSON payload to your URL containing the messageHandle, status (SENT, DELIVERED, READ, or FAILED), and recipient number. Use Axum or Actix-web in Rust to receive these webhooks and update your database accordingly.
Next steps
- API reference — Full Sendblue endpoint documentation including group messaging, typing indicators, and read receipts
- iMessage API overview — How Sendblue's iMessage infrastructure works under the hood
- Webhook docs — Complete webhook payload schema and retry behavior
Ready to send iMessages from Rust?
Free sandbox, no credit card required. Get API keys in minutes.