sendly 3.37.0

Official Rust SDK for the Sendly SMS API
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
//! Numbers Resource — Phone Number Discovery & Provisioning
//!
//! Browse the countries and number types Sendly can provision, search
//! available numbers (already priced for your account), list the numbers you
//! own, and buy a new one.
//!
//! Buying a number is asynchronous. [`NumbersResource::buy`] returns `202`
//! with a [`BuyNumberResponse::status`]:
//!
//! - `provisioning` — the number is being set up; poll [`NumbersResource::list`]
//!   until it appears as active.
//! - `documents_required` / `payment_required` — the purchase needs the user
//!   to finish something on a hosted Sendly page first. The response carries an
//!   [`NumberBuyAction`] with a `url` (the hosted page) and a short `code` the
//!   user types to prove they have terminal access. Hand the user the URL +
//!   code, wait for the action to complete, then call `buy()` again with the
//!   SAME body plus `action_code` set to the completed action's code.
//!
//! See <https://sendly.live/docs/numbers> for the full flow.

use serde::{Deserialize, Serialize};

use crate::client::Sendly;
use crate::error::{Error, Result};

/// A country Sendly can provision numbers in.
#[derive(Debug, Clone, Deserialize)]
pub struct NumberCountry {
    /// ISO 3166-1 alpha-2 country code (e.g. `GB`).
    pub code: String,
    /// Human-readable country name.
    pub name: String,
    /// Number types available in this country (e.g. `["mobile", "local"]`).
    #[serde(default, alias = "numberTypes")]
    pub number_types: Vec<String>,
}

/// Response from [`NumbersResource::list_countries`].
#[derive(Debug, Clone, Deserialize)]
pub struct NumberCountriesResponse {
    #[serde(default)]
    pub countries: Vec<NumberCountry>,
}

/// A number available to buy. `monthly_cost` is already priced for your
/// account (a display string).
#[derive(Debug, Clone, Deserialize)]
pub struct AvailableNumber {
    /// Phone number in E.164 format.
    #[serde(alias = "phoneNumber")]
    pub phone_number: String,
    /// ISO 3166-1 alpha-2 country code.
    pub country: String,
    /// Number type (e.g. `mobile`, `local`, `toll_free`).
    #[serde(alias = "numberType")]
    pub number_type: String,
    /// Customer-facing monthly cost (already marked up), as a string.
    #[serde(alias = "monthlyCost")]
    pub monthly_cost: String,
    /// ISO 4217 currency code for `monthly_cost` (e.g. `USD`).
    pub currency: String,
}

/// Response from [`NumbersResource::list_available`].
#[derive(Debug, Clone, Deserialize)]
pub struct AvailableNumbersResponse {
    #[serde(default)]
    pub numbers: Vec<AvailableNumber>,
}

/// A phone number you own.
#[derive(Debug, Clone, Deserialize)]
pub struct OwnedNumber {
    /// Unique number identifier.
    pub id: String,
    /// Phone number in E.164 format.
    #[serde(alias = "phoneNumber")]
    pub phone_number: String,
    /// Provisioning / lifecycle status.
    pub status: String,
    /// How the number was acquired (e.g. `purchased`, `ported`).
    ///
    /// Absent on the trimmed `number` returned by a buy response.
    #[serde(default)]
    pub source: Option<String>,
    /// ISO 3166-1 alpha-2 country code.
    ///
    /// Absent on the trimmed `number` returned by a buy response.
    #[serde(default, alias = "countryCode")]
    pub country_code: Option<String>,
    /// Number type (e.g. `mobile`, `local`, `toll_free`).
    ///
    /// Absent on the trimmed `number` returned by a buy response.
    #[serde(default, alias = "phoneNumberType")]
    pub phone_number_type: Option<String>,
    /// Monthly cost in cents.
    #[serde(default, alias = "monthlyCostCents")]
    pub monthly_cost_cents: i64,
    /// Whether this is the workspace's default sending number.
    ///
    /// Present on the single-number responses ([`NumbersResource::get`],
    /// [`NumbersResource::update`]); omitted from the [`NumbersResource::list`]
    /// projection (then `None`).
    #[serde(default, alias = "isDefault")]
    pub is_default: Option<bool>,
    /// When regulatory documents were submitted for carrier review, as an
    /// ISO-8601 timestamp. `None` means the number still needs documents.
    #[serde(default, alias = "requirementsSubmittedAt")]
    pub requirements_submitted_at: Option<String>,
    /// Whether the number is scheduled for release at the end of the period.
    #[serde(default, alias = "pendingCancellation")]
    pub pending_cancellation: bool,
    /// When the number is scheduled to be released, as an ISO-8601 timestamp.
    #[serde(default, alias = "scheduledReleaseAt")]
    pub scheduled_release_at: Option<String>,
}

/// Response from [`NumbersResource::list`].
#[derive(Debug, Clone, Deserialize)]
pub struct OwnedNumbersResponse {
    #[serde(default)]
    pub numbers: Vec<OwnedNumber>,
}

/// Hosted-page hand-off returned when a buy needs the user to finish a step.
///
/// Hand the user the [`url`](Self::url) and the short [`code`](Self::code)
/// (the code proves they have terminal access). Once they complete the hosted
/// page, re-call [`NumbersResource::buy`] with the same body plus
/// `action_code` set to this code.
#[derive(Debug, Clone, Deserialize)]
pub struct NumberBuyAction {
    /// Hosted Sendly page URL to send the user to.
    pub url: String,
    /// Short user code the user enters to prove terminal access. Display only —
    /// never pass this where `action_code` is expected.
    pub code: String,
    /// The action identifier (32-hex). Use this to poll the action's status and
    /// to re-call `buy()` (set it on [`BuyNumberRequest::action_code`]).
    #[serde(default, alias = "actionCode")]
    pub action_code: Option<String>,
    /// When this action expires, as epoch milliseconds.
    #[serde(default, alias = "expiresAt")]
    pub expires_at: Option<i64>,
}

/// Response from [`NumbersResource::buy`].
///
/// When `status` is `documents_required` or `payment_required`, `action`
/// carries the hosted-page hand-off (URL + code). Hand it to the user, wait
/// for completion, then call `buy()` again with the same body plus
/// `action_code` set to the completed action's code.
#[derive(Debug, Clone, Deserialize)]
pub struct BuyNumberResponse {
    /// Buy outcome: `provisioning`, `documents_required`, or `payment_required`.
    pub status: String,
    /// The provisioned number, when available.
    #[serde(default)]
    pub number: Option<OwnedNumber>,
    /// Outstanding requirements, when `status` is `documents_required`.
    #[serde(default)]
    pub requirements: Option<Vec<serde_json::Value>>,
    /// Hosted-page hand-off, when `status` requires user action.
    #[serde(default)]
    pub action: Option<NumberBuyAction>,
}

/// Options for [`NumbersResource::list_available`].
#[derive(Debug, Clone, Default)]
pub struct ListAvailableNumbersOptions {
    /// ISO 3166-1 alpha-2 country code to search in (e.g. `GB`).
    pub country: String,
    /// Number type to search for (e.g. `mobile`).
    pub r#type: String,
    /// Optional substring the number must contain (digits only).
    pub contains: Option<String>,
}

impl ListAvailableNumbersOptions {
    pub fn new(country: impl Into<String>, r#type: impl Into<String>) -> Self {
        Self {
            country: country.into(),
            r#type: r#type.into(),
            contains: None,
        }
    }

    pub fn contains(mut self, contains: impl Into<String>) -> Self {
        self.contains = Some(contains.into());
        self
    }

    pub(crate) fn to_query_params(&self) -> Vec<(String, String)> {
        let mut params = vec![
            ("country".to_string(), self.country.clone()),
            ("type".to_string(), self.r#type.clone()),
        ];
        if let Some(ref contains) = self.contains {
            params.push(("contains".to_string(), contains.clone()));
        }
        params
    }
}

/// Request body for [`NumbersResource::buy`].
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BuyNumberRequest {
    /// Phone number to buy, in E.164 format.
    pub phone_number: String,
    /// ISO 3166-1 alpha-2 country code.
    pub country_code: String,
    /// Number type (e.g. `mobile`, `local`, `toll_free`).
    pub phone_number_type: String,
    /// Customer-facing monthly cost from the available-numbers listing.
    pub monthly_cost: String,
    /// The code from a COMPLETED action. Set this only when re-calling `buy()`
    /// after the user finished a `documents_required` / `payment_required`
    /// hosted-page step.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub action_code: Option<String>,
}

impl BuyNumberRequest {
    pub fn new(
        phone_number: impl Into<String>,
        country_code: impl Into<String>,
        phone_number_type: impl Into<String>,
        monthly_cost: impl Into<String>,
    ) -> Self {
        Self {
            phone_number: phone_number.into(),
            country_code: country_code.into(),
            phone_number_type: phone_number_type.into(),
            monthly_cost: monthly_cost.into(),
            action_code: None,
        }
    }

    /// Set the code from a completed documents/payment action before
    /// re-calling `buy()`.
    pub fn action_code(mut self, action_code: impl Into<String>) -> Self {
        self.action_code = Some(action_code.into());
        self
    }
}

/// Request body for [`NumbersResource::update`].
///
/// Supply at least one field. Only these two mutations are supported:
///
/// - `is_default: true` — make this number the workspace's default sender. The
///   number must be `active`, or the call fails with an `invalid_state`
///   validation error.
/// - `pending_cancellation: false` — cancel a previously scheduled release and
///   keep the number.
///
/// A body with neither field is rejected before it reaches the API. Build one
/// with [`UpdateNumberRequest::make_default`] and/or
/// [`UpdateNumberRequest::keep`].
#[derive(Debug, Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateNumberRequest {
    /// Set to `true` to make this the workspace's default sender (requires an
    /// `active` number).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_default: Option<bool>,
    /// Set to `false` to cancel a scheduled release and keep the number.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pending_cancellation: Option<bool>,
}

impl UpdateNumberRequest {
    /// Creates a new, empty update request.
    pub fn new() -> Self {
        Self::default()
    }

    /// Make this number the workspace's default sender (requires an `active`
    /// number).
    pub fn make_default(mut self) -> Self {
        self.is_default = Some(true);
        self
    }

    /// Cancel a previously scheduled release and keep the number.
    pub fn keep(mut self) -> Self {
        self.pending_cancellation = Some(false);
        self
    }

    fn has_mutation(&self) -> bool {
        self.is_default.is_some() || self.pending_cancellation.is_some()
    }
}

/// Response from [`NumbersResource::release`].
///
/// - Immediate release: `success` is `true` and `scheduled` is `None`.
/// - Scheduled release (a live paid purchase is cancelled at the end of the
///   paid period): `scheduled` is `Some(true)` and `scheduled_release_at`
///   carries the ISO-8601 effective time.
#[derive(Debug, Clone, Deserialize)]
pub struct ReleaseNumberResponse {
    /// Always `true` on success.
    #[serde(default)]
    pub success: bool,
    /// `true` when the release was scheduled for the end of the paid period.
    #[serde(default)]
    pub scheduled: Option<bool>,
    /// When the scheduled release takes effect, as an ISO-8601 timestamp.
    #[serde(default, alias = "scheduledReleaseAt")]
    pub scheduled_release_at: Option<String>,
}

/// Numbers resource — discover, buy, and list phone numbers.
///
/// # Example
///
/// ```rust,no_run
/// use sendly::{Sendly, ListAvailableNumbersOptions, BuyNumberRequest};
///
/// # async fn run() -> Result<(), sendly::Error> {
/// let client = Sendly::new("sk_live_v1_xxx");
///
/// // 1) Browse what's available
/// let countries = client.numbers().list_countries().await?;
/// let available = client
///     .numbers()
///     .list_available(ListAvailableNumbersOptions::new("GB", "mobile"))
///     .await?;
///
/// // 2) Buy one
/// let first = &available.numbers[0];
/// let result = client
///     .numbers()
///     .buy(BuyNumberRequest::new(
///         &first.phone_number,
///         &first.country,
///         &first.number_type,
///         &first.monthly_cost,
///     ))
///     .await?;
///
/// if result.status == "provisioning" {
///     println!("Number is being set up");
/// } else if let Some(action) = result.action {
///     // documents_required / payment_required: send the user to the hosted page
///     println!("Finish at {} (code {})", action.url, action.code);
///     // ...after they complete it, re-call buy() with action_code set.
/// }
///
/// // 3) List the numbers you own
/// let owned = client.numbers().list().await?;
/// # Ok(()) }
/// ```
pub struct NumbersResource<'a> {
    client: &'a Sendly,
}

impl<'a> NumbersResource<'a> {
    pub(crate) fn new(client: &'a Sendly) -> Self {
        Self { client }
    }

    /// List the countries Sendly can provision numbers in, with the number
    /// types available in each.
    pub async fn list_countries(&self) -> Result<NumberCountriesResponse> {
        let response = self.client.get("/numbers/countries", &[]).await?;
        Ok(response.json().await?)
    }

    /// Search numbers available to buy in a country. Prices are already
    /// customer-priced for your account.
    pub async fn list_available(
        &self,
        options: ListAvailableNumbersOptions,
    ) -> Result<AvailableNumbersResponse> {
        if options.country.is_empty() {
            return Err(Error::Validation {
                message: "list_available requires a country".to_string(),
            });
        }
        if options.r#type.is_empty() {
            return Err(Error::Validation {
                message: "list_available requires a type".to_string(),
            });
        }
        let params = options.to_query_params();
        let response = self.client.get("/numbers/available", &params).await?;
        Ok(response.json().await?)
    }

    /// List the phone numbers you own.
    pub async fn list(&self) -> Result<OwnedNumbersResponse> {
        let response = self.client.get("/numbers", &[]).await?;
        Ok(response.json().await?)
    }

    /// Get a single phone number you own by id. Unlike [`NumbersResource::list`],
    /// the returned record includes [`OwnedNumber::is_default`].
    pub async fn get(&self, id: &str) -> Result<OwnedNumber> {
        if id.is_empty() {
            return Err(Error::Validation {
                message: "Number ID is required".to_string(),
            });
        }
        let encoded_id = urlencoding::encode(id);
        let path = format!("/numbers/{}", encoded_id);
        let response = self.client.get(&path, &[]).await?;
        Ok(response.json().await?)
    }

    /// Update a phone number you own. Supply at least one supported mutation via
    /// [`UpdateNumberRequest`]:
    ///
    /// - [`make_default`](UpdateNumberRequest::make_default) — make this the
    ///   workspace's default sending number (the number must be `active`).
    /// - [`keep`](UpdateNumberRequest::keep) — cancel a previously scheduled
    ///   release and keep the number.
    ///
    /// Returns the updated record (including [`OwnedNumber::is_default`]).
    pub async fn update(
        &self,
        id: &str,
        request: UpdateNumberRequest,
    ) -> Result<OwnedNumber> {
        if id.is_empty() {
            return Err(Error::Validation {
                message: "Number ID is required".to_string(),
            });
        }
        if !request.has_mutation() {
            return Err(Error::Validation {
                message:
                    "Provide at least one of make_default() or keep() (isDefault / pendingCancellation)"
                        .to_string(),
            });
        }
        let encoded_id = urlencoding::encode(id);
        let path = format!("/numbers/{}", encoded_id);
        let response = self.client.patch(&path, &request).await?;
        Ok(response.json().await?)
    }

    /// Release a phone number you own. A live paid purchase is cancelled at the
    /// end of the paid period (the response then carries `scheduled: true` and a
    /// `scheduled_release_at`); everything else is released immediately.
    pub async fn release(&self, id: &str) -> Result<ReleaseNumberResponse> {
        if id.is_empty() {
            return Err(Error::Validation {
                message: "Number ID is required".to_string(),
            });
        }
        let encoded_id = urlencoding::encode(id);
        let path = format!("/numbers/{}", encoded_id);
        let response = self.client.delete(&path).await?;
        Ok(response.json().await?)
    }

    /// Buy a phone number. Asynchronous — returns `202` with a status.
    ///
    /// When the status is `documents_required` or `payment_required`, the
    /// response carries an [`NumberBuyAction`] hand-off (hosted-page URL +
    /// short code). Hand it to the user, wait for them to complete it, then
    /// call `buy()` again with the SAME body plus `action_code` set to the
    /// completed action's code.
    pub async fn buy(&self, request: BuyNumberRequest) -> Result<BuyNumberResponse> {
        let response = self.client.post("/numbers/buy", &request).await?;
        Ok(response.json().await?)
    }
}