sendly 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md CHANGED
@@ -18,7 +18,7 @@ Official Ruby SDK for the Sendly SMS API.
18
18
  gem install sendly
19
19
 
20
20
  # Bundler (add to Gemfile)
21
- gem 'sendly'
21
+ gem 'sendly', '~> 4.2'
22
22
 
23
23
  # then run
24
24
  bundle install
@@ -34,7 +34,7 @@ client = Sendly::Client.new("sk_live_v1_your_api_key")
34
34
 
35
35
  # Send an SMS
36
36
  message = client.messages.send(
37
- to: "+15551234567",
37
+ to: "+15125550123",
38
38
  text: "Hello from Sendly!"
39
39
  )
40
40
 
@@ -48,7 +48,7 @@ Before sending live SMS messages, you need:
48
48
 
49
49
  1. **Business Verification** - Complete verification in the [Sendly dashboard](https://sendly.live/dashboard)
50
50
  - **International**: Instant approval (just provide Sender ID)
51
- - **US/Canada**: Requires carrier approval (3-7 business days)
51
+ - **US/Canada**: Requires carrier approval
52
52
 
53
53
  2. **Credits** - Add credits to your account
54
54
  - Test keys (`sk_test_*`) work without credits (sandbox mode)
@@ -76,7 +76,7 @@ Sendly.configure do |config|
76
76
  end
77
77
 
78
78
  # Use the default client
79
- Sendly.send_message(to: "+15551234567", text: "Hello!")
79
+ Sendly.send_message(to: "+15125550123", text: "Hello!")
80
80
  ```
81
81
 
82
82
  ### Client Options
@@ -86,10 +86,19 @@ client = Sendly::Client.new(
86
86
  "sk_live_v1_xxx",
87
87
  base_url: "https://sendly.live/api/v1",
88
88
  timeout: 60,
89
- max_retries: 5
89
+ max_retries: 5,
90
+ organization_id: "org_abc" # sent as X-Organization-Id; defaults to ENV["SENDLY_ORG_ID"]
90
91
  )
92
+
93
+ # The API key is also accepted as a keyword, for callers on the older signature.
94
+ client = Sendly::Client.new(api_key: "sk_live_v1_xxx")
91
95
  ```
92
96
 
97
+ The key is validated in the constructor: anything that is not
98
+ `sk_test_v1_…` or `sk_live_v1_…` raises `Sendly::AuthenticationError`
99
+ before a request is made. `organization_id` is also writable after
100
+ construction (`client.organization_id = "org_abc"`).
101
+
93
102
  ## Messages
94
103
 
95
104
  ### Send an SMS
@@ -97,20 +106,20 @@ client = Sendly::Client.new(
97
106
  ```ruby
98
107
  # Marketing message (default)
99
108
  message = client.messages.send(
100
- to: "+15551234567",
109
+ to: "+15125550123",
101
110
  text: "Check out our new features!"
102
111
  )
103
112
 
104
113
  # Transactional message (bypasses quiet hours)
105
114
  message = client.messages.send(
106
- to: "+15551234567",
115
+ to: "+15125550123",
107
116
  text: "Your verification code is: 123456",
108
117
  message_type: "transactional"
109
118
  )
110
119
 
111
120
  # With custom metadata (max 4KB)
112
121
  message = client.messages.send(
113
- to: "+15551234567",
122
+ to: "+15125550123",
114
123
  text: "Your order #12345 has shipped!",
115
124
  metadata: { order_id: "12345", customer_id: "cust_abc" }
116
125
  )
@@ -118,9 +127,9 @@ message = client.messages.send(
118
127
  # Send from one of your owned numbers (or an alphanumeric sender ID).
119
128
  # Omit `from` to use your default sender.
120
129
  message = client.messages.send(
121
- to: "+15551234567",
130
+ to: "+15125550123",
122
131
  text: "Hello from our team!",
123
- from: "+447111111111"
132
+ from: "+447700900123"
124
133
  )
125
134
 
126
135
  puts message.id
@@ -138,16 +147,18 @@ messages.each { |m| puts m.to }
138
147
  # With filters
139
148
  messages = client.messages.list(
140
149
  status: "delivered",
141
- to: "+15551234567",
150
+ to: "+15125550123",
142
151
  limit: 20,
143
152
  offset: 0
144
153
  )
145
154
 
146
- # Pagination info
155
+ # Pagination info: total counts every matching message, not just this page
147
156
  puts messages.total
148
157
  puts messages.has_more
149
158
  ```
150
159
 
160
+ A page holds at most 100 messages; a larger `limit:` is capped at 100.
161
+
151
162
  ### Get a Message
152
163
 
153
164
  ```ruby
@@ -164,45 +175,49 @@ puts message.delivered_at
164
175
  ```ruby
165
176
  # Schedule a message for future delivery
166
177
  scheduled = client.messages.schedule(
167
- to: "+15551234567",
178
+ to: "+15125550123",
168
179
  text: "Your appointment is tomorrow!",
169
- scheduled_at: "2025-01-15T10:00:00Z"
180
+ scheduled_at: (Time.now.utc + 3600).iso8601 # 5 minutes to 5 days ahead
170
181
  )
171
182
 
172
- puts scheduled.id
173
- puts scheduled.scheduled_at
183
+ # schedule returns the raw Hash the API sent, not a Message object
184
+ puts scheduled["id"]
185
+ puts scheduled["scheduledAt"]
174
186
 
175
187
  # List scheduled messages (returns a Hash with "data" array)
176
188
  result = client.messages.list_scheduled
177
189
  result["data"].each { |msg| puts "#{msg['id']}: #{msg['scheduledAt']}" }
178
190
 
179
191
  # Get a specific scheduled message
180
- msg = client.messages.get_scheduled("sched_xxx")
192
+ msg = client.messages.get_scheduled("schd_xxx")
181
193
 
182
194
  # Cancel a scheduled message (refunds credits)
183
- result = client.messages.cancel_scheduled("sched_xxx")
195
+ result = client.messages.cancel_scheduled("schd_xxx")
184
196
  puts "Refunded: #{result['creditsRefunded']} credits"
185
197
  ```
186
198
 
187
199
  ### Batch Messages
188
200
 
189
201
  ```ruby
190
- # Send multiple messages in one API call (up to 1000)
202
+ # Send multiple messages in one API call (up to 10,000)
191
203
  batch = client.messages.send_batch(
192
204
  messages: [
193
- { to: "+15551234567", text: "Hello User 1!" },
194
- { to: "+15559876543", text: "Hello User 2!" },
195
- { to: "+15551112222", text: "Hello User 3!" }
205
+ { to: "+15125550123", text: "Hello User 1!" },
206
+ { to: "+15125550124", text: "Hello User 2!" },
207
+ { to: "+15125550125", text: "Hello User 3!" }
196
208
  ]
197
209
  )
198
210
 
199
211
  puts batch["batchId"]
200
- puts "Queued: #{batch['queued']}"
212
+ puts "Status: #{batch['status']}" # "processing" until the batch finishes
213
+ puts "Sent: #{batch['sent']}"
201
214
  puts "Failed: #{batch['failed']}"
202
215
  puts "Credits used: #{batch['creditsUsed']}"
203
216
 
204
- # Get batch status
217
+ # Get batch status. The stored batch — unlike the send response — also
218
+ # carries queued/delivered counts.
205
219
  status = client.messages.get_batch("batch_xxx")
220
+ puts "#{status['queued']} queued, #{status['delivered']} delivered"
206
221
 
207
222
  # List all batches
208
223
  batches = client.messages.list_batches
@@ -210,16 +225,21 @@ batches = client.messages.list_batches
210
225
  # Preview batch (dry run) - validates without sending
211
226
  preview = client.messages.preview_batch(
212
227
  messages: [
213
- { to: '+15551234567', text: 'Hello User 1!' },
228
+ { to: '+15125550123', text: 'Hello User 1!' },
214
229
  { to: '+447700900123', text: 'Hello UK!' }
215
230
  ]
216
231
  )
217
- puts "Credits needed: #{preview['creditsNeeded']}"
218
- puts "Will send: #{preview['willSend']}, Blocked: #{preview['blocked']}"
232
+ puts "Credits needed: #{preview['creditsNeeded']} (balance #{preview['creditBalance']})"
233
+ puts "Sendable: #{preview['sendable']} of #{preview['total']}, blocked: #{preview['blocked']}"
234
+ puts "Enough credits? #{preview['hasSufficientCredits']}"
219
235
  ```
220
236
 
221
237
  ### Iterate All Messages
222
238
 
239
+ `each` requests page after page until it has yielded every matching message,
240
+ so on a large account it makes many requests; `break` out of the block to stop
241
+ early. Without a block it returns an `Enumerator`.
242
+
223
243
  ```ruby
224
244
  # Auto-pagination
225
245
  client.messages.each do |message|
@@ -242,7 +262,7 @@ the `group_mms` feature (and `enable_mms` when sending media).
242
262
 
243
263
  ```ruby
244
264
  group = client.messages.send_group(
245
- to: ["+14155551234", "+14155555678"],
265
+ to: ["+14155550123", "+14155550124"],
246
266
  text: "Hey team - quick sync at noon?"
247
267
  )
248
268
 
@@ -251,15 +271,23 @@ puts group.group_message_id # => "grp_..." (present on live sends)
251
271
  puts group.status # => "sent" (or "delivered" when simulated)
252
272
  puts group.simulated? # => true on test keys / before verification
253
273
 
274
+ # Recipients: on a live send each entry is a Hash with the per-recipient
275
+ # status ({ "phoneNumber" => "+14155550123", "status" => "queued" });
276
+ # on a simulated send each entry is the phone number String.
277
+ group.to.each { |r| puts r.is_a?(Hash) ? "#{r['phoneNumber']} #{r['status']}" : r }
278
+
254
279
  # With media instead of (or in addition to) text
255
280
  client.messages.send_group(
256
- to: ["+14155551234", "+14155555678"],
257
- media_urls: ["https://cdn.example.com/flyer.jpg"],
281
+ to: ["+14155550123", "+14155550124"],
282
+ media_urls: ["https://cdn.acme.example/flyer.jpg"],
258
283
  message_type: "marketing"
259
284
  )
260
285
  ```
261
286
 
262
- Billed per recipient. US/Canada destinations only.
287
+ Billed per recipient. US/Canada destinations only. Without the `group_mms`
288
+ feature the API answers 403 `feature_disabled` (`Sendly::APIError`). A group
289
+ message the carrier refuses raises `Sendly::ValidationError` (422
290
+ `send_failed`), and its credits are refunded.
263
291
 
264
292
  ### AI Message Enhancement
265
293
 
@@ -282,30 +310,53 @@ puts result.model # model used (when available)
282
310
 
283
311
  ## Idempotency
284
312
 
285
- Every POST carries an automatically generated `Idempotency-Key` header, held
286
- across the client's own rate-limit retries, so a retry of a request that
287
- already reached the API returns the original result instead of sending and
288
- charging again. Pass your own key (1-255 printable ASCII characters) when the
289
- guarantee needs to outlive the process, such as a job queue that re-runs after
290
- a crash or your own retry loop; `idempotency_key:` is accepted on
291
- `messages.send`, `send_group`, `schedule`, and `send_batch`.
313
+ Every POST carries an automatically generated `Idempotency-Key` header. The
314
+ client keeps that key for every retry it makes on its own (a 5xx, or a 429 it
315
+ waits out; see [Retries and rate limits](#retries-and-rate-limits)), so a retry
316
+ of a request that already reached the API returns the original result instead
317
+ of sending and charging again. Pass your own key (1-255 printable ASCII
318
+ characters) when the guarantee needs to outlive the process, such as a job
319
+ queue that re-runs after a crash or your own retry loop; `idempotency_key:` is
320
+ accepted on `messages.send`, `send_group`, `schedule`, and `send_batch`.
292
321
 
293
322
  ```ruby
294
323
  message = client.messages.send(
295
- to: "+15551234567",
324
+ to: "+15125550123",
296
325
  text: "Your order has shipped!",
297
326
  idempotency_key: "order-4821-shipped"
298
327
  )
299
328
  ```
300
329
 
301
- Repeating a request with the same key within 24 hours returns the original
302
- response; `send_batch` sends no automatic key, because the API already
303
- deduplicates identical batches by their contents. Note this client raises
304
- `Sendly::TimeoutError` instead of retrying a timeout, so a timeout is exactly
305
- when to retry with your own key.
330
+ The API records the answer under the key for a 2xx and for every 4xx except
331
+ 429, and repeating the request with the same key within 24 hours returns that
332
+ recorded answer. A 5xx or a 429 is never recorded, so a retry under the same
333
+ key runs the request again. Reusing one of your own keys with a different body
334
+ is refused with 422 `idempotency_key_mismatch` (`Sendly::ValidationError`).
335
+
336
+ `send_batch` sends no automatic key, because the API already deduplicates
337
+ identical batches by their contents. This client raises `Sendly::TimeoutError`
338
+ and `Sendly::NetworkError` instead of retrying them, so those are exactly when
339
+ to retry with your own key.
306
340
 
307
341
  Full details: https://sendly.live/docs/idempotency
308
342
 
343
+ ## Rate Limits
344
+
345
+ Requests are counted per API key in a fixed 60-second window that opens with
346
+ the key's first request:
347
+
348
+ | Key | Requests per minute |
349
+ |-----|---------------------|
350
+ | Test (`sk_test_v1_*`) | 60 |
351
+ | Live (`sk_live_v1_*`) | 600 |
352
+ | Enterprise master key | 3000 |
353
+
354
+ Going over the limit returns a 429 `rate_limit_exceeded` with `retryAfter` in
355
+ the body. The client sleeps that long and retries, up to `max_retries` times
356
+ (default 3), then raises `Sendly::RateLimitError`, which carries `retry_after`
357
+ in seconds. Other 429s, and any wait over 60 seconds, are raised at once; see
358
+ [Retries and rate limits](#retries-and-rate-limits) for which ones.
359
+
309
360
  ## Webhooks
310
361
 
311
362
  ### Managing endpoints
@@ -313,7 +364,7 @@ Full details: https://sendly.live/docs/idempotency
313
364
  ```ruby
314
365
  # Create a webhook endpoint
315
366
  webhook = client.webhooks.create(
316
- url: "https://example.com/webhooks/sendly",
367
+ url: "https://acme.example/webhooks/sendly",
317
368
  events: ["message.delivered", "message.failed"]
318
369
  )
319
370
 
@@ -328,18 +379,62 @@ wh = client.webhooks.get("whk_xxx")
328
379
 
329
380
  # Update a webhook
330
381
  client.webhooks.update("whk_xxx",
331
- url: "https://new-endpoint.example.com/webhook",
382
+ url: "https://new-endpoint.acme.example/webhook",
332
383
  events: ["message.delivered", "message.failed", "message.sent"]
333
384
  )
334
385
 
335
- # Test a webhook
386
+ # Test a webhook. A delivered test returns a Sendly::WebhookTestResult; a
387
+ # test your endpoint fails raises Sendly::ValidationError with the API's
388
+ # message ("Test webhook failed: ...").
336
389
  result = client.webhooks.test("whk_xxx")
390
+ puts "#{result.status_code} in #{result.response_time_ms}ms (#{result.delivery_id})"
337
391
 
338
- # Rotate webhook secret
392
+ # Rotate webhook secret. Deliveries are signed with the new secret from
393
+ # the moment it is issued and the old secret is not kept, so switch your
394
+ # endpoint over straight away. new_secret is shown only once.
339
395
  rotation = client.webhooks.rotate_secret("whk_xxx")
396
+ puts rotation.new_secret
397
+ puts rotation.new_secret_version
340
398
 
341
399
  # Delete a webhook
342
400
  client.webhooks.delete("whk_xxx")
401
+
402
+ # Delivery history, newest first (limit defaults to 50, max 100), and a
403
+ # manual retry of one delivery
404
+ client.webhooks.deliveries("whk_xxx", status: "failed", limit: 20).each do |d|
405
+ puts "#{d.event_type} #{d.status} (attempt #{d.attempt_number}/#{d.max_attempts})"
406
+ end
407
+ client.webhooks.retry_delivery("whk_xxx", "del_xxx")
408
+
409
+ # Event types the API describes (a subset: subscribe accepts every
410
+ # Sendly::Webhooks::EVENT_* constant except EVENT_MESSAGE_QUEUED)
411
+ client.webhooks.event_types.each { |t| puts t }
412
+ ```
413
+
414
+ Webhook ids begin `whk_` and delivery ids `del_`; both are checked in the
415
+ client, so a malformed id raises `ArgumentError` before a request goes out.
416
+ `create` likewise rejects a non-HTTPS URL and an empty event list. Signing
417
+ secrets begin `whsec_`.
418
+
419
+ After an outage the endpoint can be caught up, and a tripped circuit
420
+ breaker reset. Both recovery calls are rejected with HTTP 409 while the
421
+ circuit is still open, so reset first:
422
+
423
+ ```ruby
424
+ client.webhooks.reset_circuit("whk_xxx")
425
+
426
+ # Re-fire deliveries we recorded but could not deliver
427
+ client.webhooks.redeliver("whk_xxx",
428
+ since: (Time.now.utc - 86_400).iso8601,
429
+ event_types: ["message.delivered"],
430
+ statuses: ["failed", "cancelled"], # default
431
+ limit: 1000) # default 1000, max 10000
432
+
433
+ # Synthesize events that never got an audit row at all (what redeliver
434
+ # cannot recover). A synthesized event reuses the id the original dispatch
435
+ # used, so dedupe on event.id. Not on event.data.object.id: a message's
436
+ # sent and delivered events share it.
437
+ client.webhooks.backfill("whk_xxx", since: (Time.now.utc - 86_400).iso8601)
343
438
  ```
344
439
 
345
440
  Subscribe with the `Sendly::Webhooks::EVENT_*` constants rather than string
@@ -367,6 +462,14 @@ event = Sendly::Webhooks.parse_event(
367
462
  )
368
463
  ```
369
464
 
465
+ Both are module functions on `Sendly::Webhooks`, not methods on
466
+ `client.webhooks`, and the argument order is payload, signature, secret,
467
+ with the timestamp as a keyword. `Sendly::Webhooks.verify_signature` is the
468
+ same check on its own, returning `true`/`false` instead of raising, for when
469
+ you want to verify without parsing. Passing the timestamp is recommended:
470
+ it is what binds the signature to a time, and a payload older than
471
+ `Sendly::Webhooks::SIGNATURE_TOLERANCE_SECONDS` (300) is then rejected.
472
+
370
473
  `event.raw_object` is `data.object` exactly as it arrived, for every event
371
474
  type. `event.data` is a hash-like view of the same object: read a key with
372
475
  `[]` (String or Symbol), a reader method of the same name, or `to_h`.
@@ -401,9 +504,9 @@ was never sent:
401
504
  JSON `null`; they come back as `nil`, not `""`.
402
505
 
403
506
  `event.message` is the message view and is `nil` for every event that is not a
404
- message — `rcs_*`, `whatsapp_*`, `call.*`, `brand.*`, `campaign.*`,
405
- `assignment.*`, `number.*`, `port*`, `contact*`, `conversation.*` and
406
- `draft.*`, whose payloads are not message-shaped. `verification.*` events get
507
+ message — `rcs_*`, `whatsapp_*`, `call.*`, `short_code.*`, `brand.*`,
508
+ `campaign.*`, `assignment.*`, `number.*`, `port*`, `contact*`,
509
+ `conversation.*` and `draft.*`, whose payloads are not message-shaped. `verification.*` events get
407
510
  `event.verification`, a `Sendly::WebhookVerificationData`. `event.data` is the
408
511
  typed view where one exists and a plain `Sendly::WebhookObject` otherwise, so
409
512
  reading `data.object` works the same way for all of them, including an event
@@ -415,9 +518,12 @@ that failed under `message_id`; read the message with
415
518
 
416
519
  ### Handling a lifecycle event
417
520
 
418
- Only `message.*` events carry a message. A lifecycle event — `rcs_*`,
419
- `whatsapp_*`, `call.*`, `brand.*`, `campaign.*`, `assignment.*`, `number.*`,
420
- `port*`, `contact*`, `conversation.*`, `draft.*` — carries a different object,
521
+ Only `message.*` events carry a message, and not `message.opt_in` or
522
+ `message.opt_out`: those carry an opt-out record (`phone_number`, `keyword`,
523
+ `from_number`), so their `event.message` is `nil` too. A lifecycle event — `rcs_*`,
524
+ `whatsapp_*`, `call.*`, `short_code.*`, `brand.*`, `campaign.*`,
525
+ `assignment.*`, `number.*`, `port*`, `contact*`, `conversation.*`,
526
+ `draft.*` — carries a different object,
421
527
  so `event.message` is `nil` and you read `data.object` off `event.data` or
422
528
  `event.raw_object`.
423
529
 
@@ -476,15 +582,18 @@ reaching for a typed view.
476
582
  ## Account & Credits
477
583
 
478
584
  ```ruby
479
- # Get account information
585
+ # Get account information. name is the workspace's name; organization,
586
+ # credits, verification, api_key and limits are the API's Hashes as sent.
480
587
  account = client.account.get
481
588
  puts account.email
589
+ puts "#{account.name} (#{account.organization_id})"
590
+ puts account.verification&.dig("status")
482
591
 
483
- # Check credit balance
592
+ # Check credit balance (a Sendly::Credits object, not a Hash)
484
593
  credits = client.account.credits
485
- puts "Available: #{credits['availableBalance']} credits"
486
- puts "Reserved: #{credits['reservedBalance']} credits"
487
- puts "Total: #{credits['balance']} credits"
594
+ puts "Available: #{credits.available_balance} credits"
595
+ puts "Reserved: #{credits.reserved_balance} credits"
596
+ puts "Total: #{credits.balance} credits"
488
597
 
489
598
  # View credit transaction history
490
599
  transactions = client.account.transactions
@@ -498,8 +607,11 @@ keys.each do |key|
498
607
  puts "#{key.name}: #{key.prefix} (#{key.type})"
499
608
  end
500
609
 
501
- # Create a new API key
502
- result = client.account.create_api_key('Production Key')
610
+ # Create a new API key. type: is "test" (the default) or "live"; scopes:
611
+ # defaults to every scope the calling key has and cannot go beyond them.
612
+ # A live key needs a verified business and credits: the API answers 403
613
+ # verification_required or 402 credits_required otherwise.
614
+ result = client.account.create_api_key('Production Key', type: 'live', scopes: ['sms:send', 'sms:read'])
503
615
  puts "New key: #{result['key']}" # Only shown once!
504
616
 
505
617
  # Rotate an API key. Issues a new key and keeps the old one valid for a grace
@@ -508,8 +620,16 @@ rotation = client.account.rotate_api_key('key_xxx', grace_period_hours: 72)
508
620
  puts "New key: #{rotation['newKey']['key']}" # Only shown once!
509
621
  puts rotation['message'] # "Old key will expire in 72 hours"
510
622
 
511
- # Revoke an API key
512
- client.account.revoke_api_key('key_xxx')
623
+ # Revoke an API key (an optional reason is recorded on the key's audit trail)
624
+ client.account.revoke_api_key('key_xxx', reason: 'rotated out of CI')
625
+
626
+ # Fetch one key, and its usage statistics
627
+ key = client.account.api_key('key_xxx')
628
+ puts "#{key.name}: #{key.scopes.join(', ')}#{key.revoked? ? ' (revoked)' : ''}"
629
+ usage = client.account.api_key_usage('key_xxx')
630
+
631
+ # Move credits to another workspace you own
632
+ client.account.transfer_credits(target_organization_id: 'org_xyz', amount: 500)
513
633
  ```
514
634
 
515
635
  ## Contacts
@@ -520,9 +640,9 @@ of `Contact` objects plus pagination fields.
520
640
  ```ruby
521
641
  # Create a contact
522
642
  contact = client.contacts.create(
523
- phone_number: "+15551234567",
643
+ phone_number: "+15125550123",
524
644
  name: "Alice Example",
525
- email: "alice@example.com",
645
+ email: "alice@acme.example",
526
646
  metadata: { plan: "pro" }
527
647
  )
528
648
 
@@ -533,7 +653,7 @@ puts result[:total]
533
653
 
534
654
  # Get, update, delete
535
655
  c = client.contacts.get(contact.id)
536
- client.contacts.update(contact.id, name: "Alice E.")
656
+ client.contacts.update(contact.id, name: "Alice E.") # see the note below
537
657
  client.contacts.delete(contact.id)
538
658
 
539
659
  # A contact's helper flags
@@ -543,21 +663,29 @@ puts c.invalid? # auto-flagged as unreachable (landline / bad number)
543
663
  # Bulk import (dedupes by phone; each entry is a Hash)
544
664
  report = client.contacts.import_contacts(
545
665
  [
546
- { phone: "+15551234567", name: "Alice" },
547
- { phone: "+15559876543", name: "Bob", email: "bob@example.com" }
666
+ { phone: "+15125550123", name: "Alice" },
667
+ { phone: "+15125550124", name: "Bob", email: "bob@acme.example" }
548
668
  ],
549
669
  list_id: "list_abc"
550
670
  )
551
- puts "Imported #{report[:imported]}, skipped #{report[:skipped_duplicates]}"
671
+ puts "Imported #{report[:imported]}, skipped #{report[:skipped_duplicates]}, errors #{report[:total_errors]}"
552
672
 
553
- # Clear the auto-invalid flag (single or bulk)
554
- client.contacts.mark_valid(contact.id)
673
+ # Clear the auto-invalid flag (single or bulk). bulk_mark_valid takes
674
+ # either ids: (up to 10,000) or list_id: — not both.
675
+ client.contacts.mark_valid("contact_1")
555
676
  client.contacts.bulk_mark_valid(list_id: "list_abc")
677
+ report = client.contacts.bulk_mark_valid(ids: ["contact_1", "contact_2"])
678
+ puts "Cleared #{report[:cleared]}"
556
679
 
557
680
  # Trigger a carrier line-type lookup (async; landlines get excluded)
558
681
  client.contacts.check_numbers(list_id: "list_abc", force: false)
559
682
  ```
560
683
 
684
+ `update` returns a `Contact` built from the API's update response, which
685
+ leaves out `opted_out` and `created_at`: on that object `opted_out?` is
686
+ `false` and `created_at` is `nil` whatever the contact holds. Read those with
687
+ `contacts.get`.
688
+
561
689
  ## Contact Lists
562
690
 
563
691
  Group contacts into lists for campaigns. Access via `client.contacts.lists`.
@@ -583,7 +711,9 @@ client.contacts.lists.delete(list.id)
583
711
 
584
712
  ## Campaigns
585
713
 
586
- Send a message to one or more contact lists as a single campaign.
714
+ Send one message to every contact on a contact list. A campaign targets a
715
+ single list: `contact_list_ids:` takes an Array, but the API refuses more than
716
+ one ID with a 400.
587
717
 
588
718
  ```ruby
589
719
  # Create a campaign
@@ -599,12 +729,21 @@ puts "Recipients: #{preview.recipient_count}"
599
729
  puts "Credits needed: #{preview.estimated_credits}"
600
730
  puts "Enough credits? #{preview.enough_credits?}"
601
731
 
602
- # Send now, or schedule for later
603
- client.campaigns.send_campaign(campaign.id)
604
- client.campaigns.schedule(campaign.id, scheduled_at: "2025-06-01T15:00:00Z", timezone: "America/New_York")
732
+ # Send now. The result is a Sendly::CampaignSendResult (a Sendly::Campaign)
733
+ # describing the batch the messages went out in: status is the batch's
734
+ # ("processing", "completed", "partial_failure" or "failed").
735
+ result = client.campaigns.send_campaign(campaign.id)
736
+ puts "Batch #{result.batch_id}: #{result.sent_count}/#{result.recipient_count} sent, " \
737
+ "#{result.failed_count} failed, #{result.credits_used} credits"
738
+
739
+ # The campaign itself is "completed" once sent
740
+ puts client.campaigns.get(campaign.id).completed?
741
+
742
+ # Or, instead of sending now, schedule a draft for later
743
+ client.campaigns.schedule(campaign.id, scheduled_at: (Time.now.utc + 86_400).iso8601, timezone: "America/New_York")
605
744
 
606
745
  # List, update, cancel, clone, delete
607
- client.campaigns.list(status: "sent")[:campaigns].each { |c| puts "#{c.name}: #{c.status}" }
746
+ client.campaigns.list(status: "completed")[:campaigns].each { |c| puts "#{c.name}: #{c.status}" }
608
747
  client.campaigns.update(campaign.id, name: "Spring Sale (v2)")
609
748
  client.campaigns.cancel(campaign.id)
610
749
  client.campaigns.clone(campaign.id)
@@ -624,9 +763,12 @@ template = client.templates.create(
624
763
  client.templates.list[:templates].each { |t| puts "#{t.name} — #{t.status}" }
625
764
  t = client.templates.get(template.id)
626
765
 
627
- # Update (drafts only), publish, delete
766
+ # Update (drafts only) and publish
628
767
  client.templates.update(template.id, text: "Hi {{name}}, your order is on the way!")
629
768
  client.templates.publish(template.id)
769
+
770
+ # Copy a template into a new draft, then delete the original
771
+ copy = client.templates.clone(template.id, name: "Order shipped (v2)")
630
772
  client.templates.delete(template.id)
631
773
 
632
774
  # Generate a template with AI
@@ -647,8 +789,10 @@ conversations.each { |c| puts "#{c.phone_number}: #{c.last_message_text}" }
647
789
  # Get one, optionally with its messages
648
790
  convo = client.conversations.get("conv_abc", include_messages: true)
649
791
 
650
- # Reply in a thread
792
+ # Reply in a thread with text, media, or both (a reply with neither raises
793
+ # Sendly::ValidationError before anything is sent). Returns a Sendly::Message.
651
794
  client.conversations.reply("conv_abc", text: "Thanks for reaching out!")
795
+ client.conversations.reply("conv_abc", media_urls: ["https://cdn.acme.example/menu.jpg"])
652
796
 
653
797
  # Lifecycle + metadata
654
798
  client.conversations.mark_read("conv_abc")
@@ -707,8 +851,8 @@ Automations that act on inbound messages based on conditions.
707
851
  ```ruby
708
852
  rule = client.rules.create(
709
853
  name: "Auto-label opt-outs",
710
- conditions: { keyword: "STOP" },
711
- actions: { add_label: "opted-out" },
854
+ conditions: { intent: "opt_out" },
855
+ actions: { addLabels: ["label_1"] },
712
856
  priority: 1
713
857
  )
714
858
 
@@ -724,7 +868,7 @@ sessions are available under `client.verify.sessions`.
724
868
 
725
869
  ```ruby
726
870
  # Send a verification code
727
- verification = client.verify.send(to: "+15551234567", app_name: "Acme")
871
+ verification = client.verify.send(to: "+15125550123", app_name: "Acme")
728
872
  puts verification.id
729
873
 
730
874
  # Check the code the user entered
@@ -734,11 +878,11 @@ puts result.verified?
734
878
  # Resend, fetch, and list
735
879
  client.verify.resend(verification.id)
736
880
  client.verify.get(verification.id)
737
- client.verify.list(status: "verified")[:verifications].each { |v| puts v.phone }
881
+ client.verify.list[:verifications].select { |v| v.status == "verified" }.each { |v| puts v.phone }
738
882
 
739
883
  # Hosted verification session (returns a URL to send the user to)
740
884
  session = client.verify.sessions.create(
741
- success_url: "https://example.com/verified",
885
+ success_url: "https://acme.example/verified",
742
886
  brand_name: "Acme"
743
887
  )
744
888
  puts session.url
@@ -756,7 +900,7 @@ media = client.media.upload("flyer.jpg", content_type: "image/jpeg")
756
900
  puts media.url
757
901
 
758
902
  # ...then attach it to a send
759
- client.messages.send(to: "+15551234567", text: "Check this out!", media_urls: [media.url])
903
+ client.messages.send(to: "+15125550123", text: "Check this out!", media_urls: [media.url])
760
904
  ```
761
905
 
762
906
  ## Numbers
@@ -792,11 +936,14 @@ case purchase.status
792
936
  when 'provisioning'
793
937
  puts "Provisioning #{purchase.number.phone_number}"
794
938
  when 'documents_required', 'payment_required'
795
- # Hand the user the hosted page + code, wait for them to finish, then
796
- # re-call buy with the SAME arguments plus the completed action's code.
939
+ # The action carries TWO different identifiers, and they are not
940
+ # interchangeable:
941
+ # action_code - the short code you SHOW the user to type on the
942
+ # hosted page. Display only.
943
+ # action_identifier - the 32-hex identifier you PASS BACK to buy.
797
944
  puts "Visit #{purchase.action_url} and enter code #{purchase.action_code}"
798
- # ...after the action completes:
799
- # client.numbers.buy(..., action_code: purchase.action_code)
945
+ # ...after the action completes, re-call buy with the same arguments plus:
946
+ # client.numbers.buy(..., action_code: purchase.action_identifier)
800
947
  end
801
948
 
802
949
  # Get one number you own (includes is_default, which the list omits)
@@ -852,11 +999,11 @@ if check.qualified?
852
999
 
853
1000
  # Poll until carriers approve
854
1001
  approved = client.ten_dlc.get_campaign(campaign.id)
855
- puts approved.status # "pending" -> "active"
1002
+ puts approved.status # "awaiting_review" -> "pending" -> "active"
856
1003
  puts approved.throughput&.tier # e.g. "Standard"
857
1004
 
858
1005
  # 3. Assign a number you own — it can send once the assignment is Active
859
- assignment = client.ten_dlc.assign_number(campaign.id, phone_number: '+15551234567')
1006
+ assignment = client.ten_dlc.assign_number(campaign.id, phone_number: '+15125550123')
860
1007
  puts assignment.status # "Under review" -> "Active"
861
1008
  end
862
1009
 
@@ -866,6 +1013,106 @@ client.ten_dlc.list_campaigns[:campaigns].each { |c| puts "#{c.use_case} — #{c
866
1013
  client.ten_dlc.list_assignments[:assignments].each { |a| puts "#{a.phone_number} — #{a.status}" }
867
1014
  ```
868
1015
 
1016
+ ## Short Codes
1017
+
1018
+ **This SDK has no short-code resource.** There is no `client.short_codes`;
1019
+ short-code applications are handled in the dashboard, or over REST. What
1020
+ the SDK does carry is the four lifecycle events, as constants you can
1021
+ subscribe to and branch on:
1022
+
1023
+ ```ruby
1024
+ client.webhooks.create(
1025
+ url: "https://acme.example/webhooks/sendly",
1026
+ events: [
1027
+ Sendly::Webhooks::EVENT_SHORT_CODE_ACTION_REQUIRED, # something needs you
1028
+ Sendly::Webhooks::EVENT_SHORT_CODE_REJECTED, # data.object[:reason]
1029
+ Sendly::Webhooks::EVENT_SHORT_CODE_FILED, # filed with the carriers
1030
+ Sendly::Webhooks::EVENT_SHORT_CODE_LIVE # data.object[:short_code]
1031
+ ]
1032
+ )
1033
+
1034
+ case event.type
1035
+ when Sendly::Webhooks::EVENT_SHORT_CODE_LIVE
1036
+ # data.object carries short_code_id, short_code and organization_id
1037
+ puts "#{event.data[:short_code]} is live"
1038
+ when Sendly::Webhooks::EVENT_SHORT_CODE_ACTION_REQUIRED
1039
+ puts "waiting on you at stage #{event.data[:stage]}"
1040
+ end
1041
+ ```
1042
+
1043
+ These are lifecycle events, so `event.message` is `nil` and you read
1044
+ `data.object` through `event.data` or `event.raw_object`, as with every
1045
+ other non-message event.
1046
+
1047
+ To drive an application from code meanwhile, call the REST endpoints
1048
+ directly with an API key holding the `short_codes:read` /
1049
+ `short_codes:write` scopes:
1050
+
1051
+ | Method | Path | Scope |
1052
+ |--------|------|-------|
1053
+ | `GET` | `/api/v1/short_codes` | `short_codes:read` |
1054
+ | `POST` | `/api/v1/short_codes/requests` | `short_codes:write` |
1055
+ | `GET` | `/api/v1/short_codes/application` | `short_codes:read` |
1056
+ | `PUT` | `/api/v1/short_codes/application` | `short_codes:write` |
1057
+ | `POST` | `/api/v1/short_codes/application/preflight` | `short_codes:read` |
1058
+ | `POST` | `/api/v1/short_codes/application/submit` | `short_codes:write` |
1059
+
1060
+ The client's own HTTP
1061
+ methods reach them without a dedicated resource — paths are relative to the
1062
+ `/api/v1` base:
1063
+
1064
+ ```ruby
1065
+ application = client.get("/short_codes/application")
1066
+ ```
1067
+
1068
+ The write endpoints take the application form's fields as their body. This
1069
+ SDK does not model them, so check the API reference for the shape rather
1070
+ than guessing: `client.put` and `client.post` pass a Hash through as JSON
1071
+ unchanged (`client.delete` takes no body), and every `client.post` carries an
1072
+ `Idempotency-Key` as usual.
1073
+
1074
+ ## Business Entity Upgrade
1075
+
1076
+ `client.business_upgrade` runs the toll-free entity upgrade: when you form
1077
+ a new legal entity, reserve a new toll-free number under it, submit it for
1078
+ carrier review, and swap over on approval. The current number keeps sending
1079
+ throughout the 1-2 week review.
1080
+
1081
+ ```ruby
1082
+ # Validate before submitting — advisory only, nothing is written.
1083
+ # verdict is "ready", "warnings" or "blocked".
1084
+ preview = client.business_upgrade.preflight(
1085
+ business_name: "Acme Holdings LLC",
1086
+ brn: "12-3456789",
1087
+ brn_type: "EIN", # Sendly::BusinessUpgradeResource::BRN_TYPES
1088
+ brn_country: "US",
1089
+ entity_type: "PRIVATE_PROFIT" # Sendly::BusinessUpgradeResource::ENTITY_TYPES
1090
+ )
1091
+ puts preview["verdict"]
1092
+ preview["issues"].each { |i| puts "#{i['severity']} #{i['field']}: #{i['code']}" }
1093
+
1094
+ # Prefill from the caller's other verified workspaces
1095
+ best = client.business_upgrade.best_prefill
1096
+
1097
+ # Submit, with the IRS letter attached (ein_doc_path OR ein_doc, not both)
1098
+ client.business_upgrade.start("ws_abc",
1099
+ business_name: "Acme Holdings LLC",
1100
+ brn: "12-3456789",
1101
+ brn_type: "EIN",
1102
+ brn_country: "US",
1103
+ entity_type: "PRIVATE_PROFIT",
1104
+ ein_doc_path: "./CP-575.pdf")
1105
+
1106
+ client.business_upgrade.status("ws_abc") # { "pending" => nil } when none
1107
+ client.business_upgrade.resubmit("ws_abc", contact_email: "ops@acme.example")
1108
+ client.business_upgrade.cancel("ws_abc")
1109
+
1110
+ # After approval, decide what happens to the old toll-free number
1111
+ client.business_upgrade.set_disposition("ws_abc",
1112
+ disposition: "moved", # or "released"
1113
+ target_workspace_id: "ws_other")
1114
+ ```
1115
+
869
1116
  ## URL Shortening (Branded Links)
870
1117
 
871
1118
  Mint branded short links for a destination URL, list them with click
@@ -879,10 +1126,10 @@ shorteners — and give you click data.
879
1126
 
880
1127
  ```ruby
881
1128
  # Shorten a URL (must be http/https)
882
- link = client.links.create(url: "https://example.com/spring-sale?utm_source=sms")
1129
+ link = client.links.create(url: "https://acme.example/spring-sale?utm_source=sms")
883
1130
  puts link.short_url # => "https://sendly.live/l/Ab3xY7"
884
1131
  puts link.code # => "Ab3xY7"
885
- puts link.destination_url # => "https://example.com/spring-sale?utm_source=sms"
1132
+ puts link.destination_url # => "https://acme.example/spring-sale?utm_source=sms"
886
1133
 
887
1134
  # List your links with click counts (limit 1-200, default 50)
888
1135
  listing = client.links.list(limit: 20)
@@ -906,25 +1153,56 @@ puts status.disabled?
906
1153
 
907
1154
  Connect a number you own to WhatsApp, create Meta-reviewed message
908
1155
  templates, and send via `client.messages.send(channel: "whatsapp", ...)`.
909
- Connecting is a one-time $19 setup (no monthly fee) and always ends with a
910
- human step: the signup returns a connect URL a person must open in a
911
- browser and log in with Facebook to link their WhatsApp Business Account.
1156
+ Connecting is a one-time $19 setup (no monthly fee). The first number always
1157
+ ends with a human step: the signup returns a connect URL a person must open in
1158
+ a browser and log in with Facebook to link their WhatsApp Business Account.
1159
+ More numbers can then be added to that account with a 6-digit code Meta sends
1160
+ to the number, with no Facebook step (same $19 fee, refunded if it fails).
1161
+
1162
+ Sends go through `messages.send(channel: "whatsapp")` and need `sms:send`,
1163
+ not `whatsapp:write`. Reads (`signup.get`, templates, the window, senders and
1164
+ sender profiles) need `whatsapp:read` and accept test keys. Signup, template
1165
+ create/edit/delete and profile edits need `whatsapp:write` and a live key
1166
+ (otherwise 403 `whatsapp_requires_live_key`). Sends need a live key too. In a
1167
+ team workspace, connecting and profile edits need an owner or admin
1168
+ (`settings:write`), and template writes need an owner, admin or member
1169
+ (`templates:write`). A missing role returns 403 `insufficient_permissions`.
1170
+ Reading conversational components needs `whatsapp:read` too and accepts test
1171
+ keys. The profile photo, conversational component and calling changes, and
1172
+ submitting or resending a verification code, need the same as a profile edit.
1173
+
1174
+ WhatsApp is enabled per person: the user who owns the API key, not the
1175
+ workspace. While it is off, sends return 403 `whatsapp_not_enabled` and the
1176
+ `/api/v1/whatsapp/*` management routes return 404 `not_found`.
912
1177
 
913
1178
  Free-form text and media only deliver inside a 24-hour customer-service
914
1179
  window (opened by the recipient messaging you); an approved template works
915
1180
  anytime. Templates are reviewed by Meta (typically 24-48h) and categorized
916
- as authentication, utility, or marketing — pricing follows the category and
917
- destination country. Note: Meta has paused marketing template delivery to
918
- US (+1) numbers.
1181
+ as authentication, utility, or marketing; `category` is required on create,
1182
+ with no default, and an update can't change it.
1183
+
1184
+ Pricing: free-form text or media inside the 24-hour window costs 1 credit
1185
+ each for the first 1,000 per sending number per calendar month (UTC), then
1186
+ the destination's utility template price; countries without a listed price
1187
+ use the default utility price of 12 credits. Templates are priced by category
1188
+ and destination country; countries without a listed price use 33
1189
+ (marketing), 12 (utility) and 12 (authentication) credits. A failed send
1190
+ gives its slot back. Note: Meta
1191
+ has paused marketing template delivery to US (+1) numbers.
919
1192
 
920
1193
  ```ruby
921
1194
  # 1. Connect a number ($19 one-time; a human must open the connect URL)
922
- signup = client.whatsapp.signup.create(phone_number: "+15559876543")
1195
+ signup = client.whatsapp.signup.create(phone_number: "+15125550123")
923
1196
  puts "Have your user open: #{signup.connect_url}"
924
1197
 
925
- # 2. Poll until active
1198
+ # 2. Poll until active. After the Facebook step the signup is "registering"
1199
+ # while WhatsApp activates the number. Activation usually takes a few minutes
1200
+ # but can take hours. If it hasn't finished about 6 hours after the session
1201
+ # began, the session fails with registration_timeout and the fee is refunded.
1202
+ # If the connection fails, the $19 fee is refunded automatically; once a
1203
+ # number has connected, a later disconnect gets nothing back.
926
1204
  status = client.whatsapp.signup.get(signup.id)
927
- puts status.failure_reasons if status.failed?
1205
+ puts status.failure_reasons if status.failed? # e.g. ["waba_mismatch"]
928
1206
 
929
1207
  # List your connected senders
930
1208
  client.whatsapp.senders.list[:senders].each do |s|
@@ -933,23 +1211,67 @@ end
933
1211
 
934
1212
  # Read and update a sender's business profile (what recipients see when
935
1213
  # they open your details in WhatsApp)
936
- profile = client.whatsapp.senders.get_profile("+15559876543")
1214
+ profile = client.whatsapp.senders.get_profile("+15125550123")
937
1215
  puts profile.display_name
938
1216
  puts profile.about
939
1217
 
940
1218
  client.whatsapp.senders.update_profile(
941
- "+15559876543",
1219
+ "+15125550123",
942
1220
  about: "Fresh roasted coffee, delivered.", # max 139 chars
943
1221
  description: "Small-batch roaster shipping nationwide.", # max 512 chars
944
1222
  website: "https://acme.example"
945
1223
  )
946
1224
 
1225
+ # Profile photo: a JPEG or PNG of at most 5 MB, square, at least 192 px wide
1226
+ client.whatsapp.senders.upload_profile_photo("+15125550123", "logo.png",
1227
+ content_type: "image/png", filename: "logo.png")
1228
+ client.whatsapp.senders.delete_profile_photo("+15125550123")
1229
+
1230
+ # Ice breakers (tappable suggestions on a first chat) and "/" commands. Each
1231
+ # list you pass replaces the stored one; [] clears it.
1232
+ client.whatsapp.senders.update_conversational_components(
1233
+ "+15125550123",
1234
+ ice_breakers: ["Track my order", "Opening hours"], # at most 4
1235
+ commands: [{ command: "menu", description: "See today's menu" }] # at most 30
1236
+ )
1237
+ components = client.whatsapp.senders.get_conversational_components("+15125550123")
1238
+ components.commands.each { |c| puts "/#{c.command}: #{c.description}" }
1239
+
1240
+ # WhatsApp calling: WhatsApp users calling the number ring like a phone call.
1241
+ # Switch voice on for the number first. There is no API for placing WhatsApp
1242
+ # calls.
1243
+ settings = client.whatsapp.senders.set_calling("+15125550123", enabled: true)
1244
+ puts settings.calling_enabled?
1245
+
1246
+ # Add another number to the account you already connected: no Facebook step.
1247
+ # Meta sends the number a 6-digit code by text ("sms", the default) or call.
1248
+ sender = client.whatsapp.senders.list[:senders].find(&:active?)
1249
+ added = client.whatsapp.signup.create(
1250
+ phone_number: "+15125550124",
1251
+ business_account_id: sender.business_account_id,
1252
+ verification_method: "sms"
1253
+ )
1254
+ puts added.status # "verifying"
1255
+
1256
+ # The code can be read back once Meta's text arrives on the number. Until a
1257
+ # code has been submitted it is the newest code since the signup started, so
1258
+ # after a resend it still shows the earlier code until the new one arrives.
1259
+ # Once WhatsApp has checked a code, only a code that arrived after the last
1260
+ # submission or resend is returned. A 502 whatsapp_verification_unavailable
1261
+ # isn't counted, so the same unchecked code can come back, and submitting it
1262
+ # again is safe.
1263
+ code = client.whatsapp.signup.get(added.id).verification_code
1264
+ client.whatsapp.signup.verify(added.id, code: code) if code
1265
+ # Or ask for a new one (at most every 30 seconds)
1266
+ client.whatsapp.signup.resend(added.id, verification_method: "voice")
1267
+
947
1268
  # 3. Create a template (Meta reviews it, usually 24-48h)
948
1269
  template = client.whatsapp.templates.create(
949
- sender: "+15559876543",
1270
+ sender: "+15125550123",
950
1271
  name: "order_shipped",
951
1272
  language: "en_US",
952
1273
  category: "UTILITY",
1274
+ header: "Order update", # fixed text only: a header with {{n}} is refused
953
1275
  body: "Hi {{1}}, your order {{2}} has shipped!",
954
1276
  examples: { "1" => "Sam", "2" => "#4821" }
955
1277
  )
@@ -961,20 +1283,22 @@ client.whatsapp.templates.update(template.id, body: "Hi {{1}}, order {{2}} is on
961
1283
  examples: { "1" => "Sam", "2" => "#4821" })
962
1284
  client.whatsapp.templates.delete(template.id)
963
1285
 
964
- # 4. Send — free-form inside an open 24h window, template anytime
965
- window = client.whatsapp.window(from: "+15559876543", to: "+15551234567")
1286
+ # 4. Send — free-form inside an open 24h window, template anytime. The window
1287
+ # is exactly { open, expiresAt }: no window on record reads open false with
1288
+ # expires_at nil; an expired one reads open false with its past expires_at.
1289
+ window = client.whatsapp.window(from: "+15125550123", to: "+15555550100")
966
1290
  if window.open?
967
1291
  client.messages.send(
968
1292
  channel: "whatsapp",
969
- to: "+15551234567",
970
- from: "+15559876543",
1293
+ to: "+15555550100",
1294
+ from: "+15125550123",
971
1295
  text: "Your table is ready!"
972
1296
  )
973
1297
  else
974
1298
  message = client.messages.send(
975
1299
  channel: "whatsapp",
976
- to: "+15551234567",
977
- from: "+15559876543",
1300
+ to: "+15555550100",
1301
+ from: "+15125550123",
978
1302
  template: {
979
1303
  name: "order_shipped",
980
1304
  language: "en_US",
@@ -987,13 +1311,102 @@ end
987
1311
  # Media with a caption (also window-bound; one attachment per message)
988
1312
  client.messages.send(
989
1313
  channel: "whatsapp",
990
- to: "+15551234567",
991
- from: "+15559876543",
1314
+ to: "+15555550100",
1315
+ from: "+15125550123",
992
1316
  text: "Here is your receipt",
993
- media_urls: ["https://example.com/receipt.pdf"]
1317
+ media_urls: ["https://acme.example/receipt.pdf"]
994
1318
  )
995
1319
  ```
996
1320
 
1321
+ WhatsApp errors map onto the usual classes:
1322
+
1323
+ - `signup.create` raises `Sendly::ServerError` for 503 `whatsapp_unavailable`
1324
+ while WhatsApp connections are unavailable. Only signup returns it; no send
1325
+ does. Nothing is charged, the response carries a `Retry-After: 3600` header,
1326
+ and `e.response_body["retryAfter"]` (3600) says how many seconds to wait; the client
1327
+ retries it like any 5xx before raising. After 5 failed, charged signups in
1328
+ 24 hours it raises `Sendly::RateLimitError` for 429
1329
+ `whatsapp_signup_limit_reached`, which is not retried: try again the next
1330
+ day.
1331
+ - A send outside an open window raises `Sendly::ValidationError` (422
1332
+ `whatsapp_window_closed`); send a template instead.
1333
+ - `whatsapp_send_failed` is a `Sendly::ValidationError` (422) when WhatsApp
1334
+ refused the message, which is final (cached under the idempotency key and
1335
+ replayed for 24 hours), and a `Sendly::ServerError` (502) when the message
1336
+ provably never reached the carrier, so it was not sent and is safe to send
1337
+ again; the 502 is never cached and the client retries it first under the
1338
+ same idempotency key. Neither is charged.
1339
+ - `whatsapp_send_unconfirmed` is a `Sendly::APIError` with `status_code` 409:
1340
+ the outcome is unknown. The message was marked failed and refunded but may
1341
+ still be delivered, so check before sending it again (it could arrive
1342
+ twice). It is not retried automatically, and it is cached under the
1343
+ idempotency key.
1344
+ - A send while WhatsApp is off for the key's owner gets 403
1345
+ `whatsapp_not_enabled`; a test key on a send or a write gets 403
1346
+ `whatsapp_requires_live_key`.
1347
+ - `templates.create` for a sender that isn't connected raises
1348
+ `Sendly::NotFoundError` (404 `whatsapp_sender_not_connected`), checked
1349
+ before anything else.
1350
+ - A template the API refuses for its content raises `Sendly::ValidationError`
1351
+ (400) with a `template_*` code in `e.response_body["error"]`:
1352
+ `template_category_invalid` (category missing or not one of the three),
1353
+ `template_authentication_otp_button_required`,
1354
+ `template_authentication_no_links` (a link in the body or a URL button on an
1355
+ authentication template) or `template_header_variable_unsupported`. A
1356
+ marketing template without an opt-out button only gets a warning. `template_already_exists`,
1357
+ `template_name_locked` and `template_not_editable` are a `Sendly::APIError`
1358
+ with `status_code` 409, and `template_not_found` is a
1359
+ `Sendly::NotFoundError`.
1360
+ - Adding a number by code: `signup.create` with `business_account_id:` raises
1361
+ `Sendly::NotFoundError` for 404 `whatsapp_business_account_not_found` (the
1362
+ account isn't connected in this workspace), `Sendly::ValidationError` for
1363
+ 400 `display_name_required` and for 422 `whatsapp_verification_start_failed`
1364
+ (WhatsApp refused to verify the number; final), `Sendly::APIError` (409) for
1365
+ `whatsapp_signup_in_progress` and `whatsapp_already_enabled`, and
1366
+ `Sendly::ServerError` for 502 `whatsapp_verification_start_failed` (WhatsApp
1367
+ couldn't start verifying the number; start again). Either failure fails the
1368
+ session and refunds its fee. With `business_account_id:` the client never
1369
+ retries a 5xx, a timeout or a dropped connection, because each retry would
1370
+ start a new charged session. A
1371
+ Facebook signup for a number
1372
+ that is being added by code gets 409 `whatsapp_verification_in_progress`,
1373
+ with that session's id in `e.response_body["id"]`.
1374
+ - `signup.verify` raises `Sendly::ValidationError` for 400
1375
+ `invalid_verification_code` (not 6 digits) and 422
1376
+ `whatsapp_verification_code_invalid` (a wrong code, with
1377
+ `e.response_body["attemptsRemaining"]`); `Sendly::APIError` (409) for
1378
+ `whatsapp_verification_failed` (after 5 wrong codes the session fails and
1379
+ the fee is refunded), `whatsapp_verification_busy` (try again) and
1380
+ `signup_not_active`; and `Sendly::ServerError` for 502
1381
+ `whatsapp_verification_unavailable` (the attempt isn't counted) and
1382
+ `whatsapp_activation_pending` (the code was accepted but connecting didn't
1383
+ finish; check back shortly with `signup.get`). The client never retries a
1384
+ 5xx, a timeout or a dropped connection from `signup.verify`: each
1385
+ submission uses one of the 5 attempts.
1386
+ `signup.resend` is retried like any other call.
1387
+ - `signup.resend` raises `Sendly::RateLimitError` for 429
1388
+ `whatsapp_verification_resend_too_soon` at once, with the seconds to wait in
1389
+ `e.retry_after`, and `whatsapp_verification_resend_failed` as a
1390
+ `Sendly::ValidationError` (422) or a `Sendly::ServerError` (502).
1391
+ - `senders.upload_profile_photo` raises `Sendly::ValidationError` for 400
1392
+ `whatsapp_profile_photo_invalid` (not a JPEG or PNG) and `file_required`,
1393
+ and `Sendly::APIError` with `status_code` 413 for
1394
+ `whatsapp_profile_photo_too_large`. The photo methods raise
1395
+ `Sendly::ServerError` for 502 `whatsapp_profile_update_failed`, and the
1396
+ conversational component methods for 502
1397
+ `whatsapp_conversational_components_fetch_failed` and
1398
+ `whatsapp_conversational_components_update_failed`; an invalid list is a
1399
+ `Sendly::ValidationError` (400 `invalid_request`) with a message naming the
1400
+ problem. The client never retries a 5xx, a timeout or a dropped connection
1401
+ from `senders.upload_profile_photo`.
1402
+ - `senders.set_calling` raises `Sendly::APIError` (409) for `voice_not_enabled`
1403
+ (switch voice on for the number first), `Sendly::ValidationError` for 422
1404
+ `whatsapp_calling_unavailable` (Meta only allows calling once the account may
1405
+ message at least 2,000 people a day and the display name is approved), and
1406
+ `Sendly::ServerError` for 502 `whatsapp_calling_update_failed`.
1407
+ - A sender method for a number that isn't connected raises
1408
+ `Sendly::NotFoundError` (404 `whatsapp_sender_not_connected`).
1409
+
997
1410
  ## RCS
998
1411
 
999
1412
  Send branded rich messages — text with suggested replies and actions, or
@@ -1042,7 +1455,7 @@ client.rcs.brands.update(brand.id,
1042
1455
  display_name: "Acme Coffee",
1043
1456
  legal_entity_type: "LIMITED_LIABILITY_COMPANY",
1044
1457
  contact: { first_name: "Sam", last_name: "Lee", email: "sam@acme.example",
1045
- phone_number: "+15551234567" }
1458
+ phone_number: "+15125550123" }
1046
1459
  )
1047
1460
 
1048
1461
  # Draft the agent. Media must be public https URLs.
@@ -1057,7 +1470,7 @@ agent = client.rcs.agents.create(
1057
1470
  brand_color: "#5B3A29",
1058
1471
  privacy_policy_url: "https://acme.example/privacy",
1059
1472
  terms_and_conditions_url: "https://acme.example/terms",
1060
- phone_number: { number: "+15551234567", label: "Support" }
1473
+ phone_number: { number: "+15125550123", label: "Support" }
1061
1474
  }
1062
1475
  )
1063
1476
 
@@ -1076,7 +1489,7 @@ end
1076
1489
  # Once the agent is in testing, invite devices, describe the campaign,
1077
1490
  # then request launch
1078
1491
  client.rcs.agents.set_test_devices(agent.id, devices: [
1079
- { phone_number: "+15557654321", label: "Sam's Pixel" }
1492
+ { phone_number: "+15125550123", label: "Sam's Pixel" }
1080
1493
  ])
1081
1494
  client.rcs.agents.update(agent.id,
1082
1495
  campaign: {
@@ -1118,7 +1531,7 @@ client.rcs.agents.list[:agents].each do |a|
1118
1531
  end
1119
1532
 
1120
1533
  # Pre-flight: can this recipient receive RCS?
1121
- capability = client.rcs.capability(to: "+15551234567")
1534
+ capability = client.rcs.capability(to: "+15125550123")
1122
1535
  puts capability.capable? ? "RCS" : "would fall back to SMS"
1123
1536
 
1124
1537
  # Text with suggested replies and actions. Nested suggestion and card
@@ -1126,7 +1539,7 @@ puts capability.capable? ? "RCS" : "would fall back to SMS"
1126
1539
  # API expects.
1127
1540
  message = client.messages.send(
1128
1541
  channel: "rcs",
1129
- to: "+15551234567",
1542
+ to: "+15125550123",
1130
1543
  text: "Your order has shipped! Want live updates?",
1131
1544
  suggestions: [
1132
1545
  { reply: { text: "Yes, notify me", postbackData: "notify_yes" } },
@@ -1148,11 +1561,11 @@ end
1148
1561
  # Rich card (RCS-capable recipients only — cards have no SMS form)
1149
1562
  client.messages.send(
1150
1563
  channel: "rcs",
1151
- to: "+15551234567",
1564
+ to: "+15125550123",
1152
1565
  card: {
1153
1566
  title: "Spring collection",
1154
1567
  description: "New arrivals are in - take a look.",
1155
- mediaUrl: "https://example.com/spring.jpg", # public JPEG, PNG, or GIF
1568
+ mediaUrl: "https://acme.example/spring.jpg", # public JPEG, PNG, or GIF
1156
1569
  orientation: "vertical", # or "horizontal"
1157
1570
  suggestions: [
1158
1571
  { action: { text: "Shop now", postbackData: "shop",
@@ -1166,7 +1579,7 @@ client.messages.send(
1166
1579
  # receive RCS
1167
1580
  client.messages.send(
1168
1581
  channel: "rcs",
1169
- to: "+15551234567",
1582
+ to: "+15125550123",
1170
1583
  text: "RCS or nothing",
1171
1584
  fallback_to_sms: false
1172
1585
  )
@@ -1175,10 +1588,12 @@ client.messages.send(
1175
1588
  ## Voice Calls
1176
1589
 
1177
1590
  Place phone calls that one of your AI agents handles, list and inspect
1178
- calls, end a call early, and download recordings. Agents are configured in
1179
- the dashboard under Calls, then Agents; the number you call from must have
1180
- voice switched on there (Calls, then Settings) and an emergency address
1181
- registered before it can place outbound calls. Each `Sendly::PhoneNumber`
1591
+ calls, end a call early, and download recordings. Agents are created with
1592
+ `client.voice.agents` or in the dashboard under Calls, then Agents; the
1593
+ number you call from must have voice switched on and an emergency address
1594
+ registered before it can place outbound calls (see
1595
+ [Configure voice](#configure-voice), or Calls, then Settings in the
1596
+ dashboard). Each `Sendly::PhoneNumber`
1182
1597
  from `client.numbers.list` carries `voice_enabled?` and `voice_mode`
1183
1598
  (`"none"`, `"ring_dashboard"` or `"agent"`) so you can pick a `from`:
1184
1599
 
@@ -1193,16 +1608,16 @@ are US and Canadian numbers. Reads need the `calls:read` scope; `create`
1193
1608
  and `hangup` need `calls:write` and a live key.
1194
1609
 
1195
1610
  > **Rolling out.** Voice is enabled workspace by workspace. Until it is on
1196
- > for yours, every call method raises `Sendly::NotFoundError`
1197
- > (`voice_not_enabled`).
1611
+ > for yours, every `client.calls` and `client.voice` method raises
1612
+ > `Sendly::NotFoundError` (`voice_not_enabled`).
1198
1613
 
1199
1614
  ```ruby
1200
1615
  # Place a call. Returns at once with the call ringing; the agent greets the
1201
1616
  # callee when they answer and uses `context` for this call only.
1202
1617
  call = client.calls.create(
1203
- to: "+15555550123",
1618
+ to: "+15125550123",
1204
1619
  agent_id: "3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b",
1205
- from: "+15555550188", # optional when you have one voice number
1620
+ from: "+15555550100", # optional when you have one voice number
1206
1621
  context: "Confirm the 3pm appointment on Tuesday.",
1207
1622
  metadata: { "crmId" => "lead_8812" } # up to 20 string pairs, echoed everywhere
1208
1623
  )
@@ -1228,7 +1643,8 @@ puts page.has_more?
1228
1643
  client.calls.hangup(call.id)
1229
1644
 
1230
1645
  # Recording. The URL is signed and valid for five minutes; it is nil until
1231
- # the recording is ready. Ogg/Opus, dual channel on agent calls.
1646
+ # the recording is ready. Ogg/Opus; agent calls are dual channel, with the
1647
+ # agent on the left channel and the other party on the right.
1232
1648
  rec = client.calls.recording(call.id)
1233
1649
  if rec.ready?
1234
1650
  File.binwrite("#{call.id}.ogg", Net::HTTP.get(URI(rec.url)))
@@ -1240,7 +1656,8 @@ when the balance cannot cover one minute at the agent rate;
1240
1656
  `Sendly::NotFoundError` for `voice_not_enabled`, `outbound_calls_not_enabled`,
1241
1657
  `agent_not_found`, `number_not_found` and `call_not_found`;
1242
1658
  `Sendly::ValidationError` for `invalid_number`, `destination_not_supported`,
1243
- `agent_required`, `invalid_metadata` and `from_number_required`;
1659
+ `agent_required`, `invalid_metadata`, `from_number_required` and
1660
+ `from_number_not_supported` (a `from` that is not a US or Canadian number);
1244
1661
  `Sendly::RateLimitError` for `daily_call_limit`; `Sendly::APIError` with
1245
1662
  `status_code` 428 for `e911_required` (register an emergency address for the
1246
1663
  number), 409 for `agent_disabled`, `no_voice_number` and `lines_busy` (retry
@@ -1250,22 +1667,116 @@ shortly), or 403 for `live_key_required` and a key missing the scope; and
1250
1667
  The full list is `Sendly::Call::ERROR_CODES`; the hangup vocabulary is
1251
1668
  `Sendly::Call::HANGUP_CLASSES`.
1252
1669
 
1670
+ `call.channel` is where the call took place: `"phone"`, `"whatsapp"` (a
1671
+ WhatsApp call, for example one placed from the dashboard) or `"browser"`
1672
+ (`Sendly::Call::CHANNELS`); a value this SDK predates comes through unchanged.
1673
+ An inbound WhatsApp call can read `"phone"` until the carrier labels it.
1674
+
1253
1675
  `call.started`, `call.completed` and `call.recording.ready` webhooks carry
1254
- the same object in snake_case (`handled_by`, `hangup_class`, `billing`,
1255
- `metadata`, ...); `Sendly::Call.new(event.raw_object)` reads it.
1676
+ the same object in snake_case (`channel`, `handled_by`, `hangup_class`,
1677
+ `billing`, `metadata`, ...); read it from `event.data`, for example
1678
+ `event.data[:channel]`.
1679
+
1680
+ ### Configure voice
1681
+
1682
+ Everything a call depends on is configurable from code with `client.voice`:
1683
+ switch voice on for a number and choose how it answers, register the
1684
+ number's emergency address, and create the AI agents that talk. Reads need
1685
+ the `calls:read` scope; writes need `calls:write` and a live key. In a team
1686
+ workspace, changing a number or its emergency address also needs a role that
1687
+ can change settings, and managing agents a role that can manage API keys
1688
+ (each agent holds its own scoped sending key).
1689
+
1690
+ ```ruby
1691
+ # Numbers. Pass the number's id or its E.164 phone number.
1692
+ client.voice.numbers.list.each do |n|
1693
+ puts "#{n.phone_number} #{n.voice_mode} #{n.emergency_address&.status || 'no emergency address'}"
1694
+ end
1695
+ number = client.voice.numbers.get("+15125550123")
1696
+ puts number.rate_per_minute.agent # credits a minute when an agent answers
1697
+
1698
+ # A US or Canadian number needs an emergency address before it can place
1699
+ # calls. The first registration adds $1.50 a month to the number;
1700
+ # registering again replaces the address without a second charge.
1701
+ number = client.voice.numbers.register_emergency_address(
1702
+ "+15125550123",
1703
+ street: "500 Example Ave",
1704
+ unit: "Suite 2",
1705
+ city: "Austin",
1706
+ state: "TX",
1707
+ zip: "78701" # country: defaults to "US"
1708
+ )
1709
+ puts number.emergency_address.status
1710
+
1711
+ # Voices and agents. An agent answers real callers on any number pointed at it.
1712
+ client.voice.voices.list.each { |v| puts "#{v.id}: #{v.label}" }
1713
+
1714
+ agent = client.voice.agents.create(
1715
+ name: "Front desk",
1716
+ voice: "ashley",
1717
+ greeting: "Thanks for calling Acme, how can I help?",
1718
+ instructions: "Answer questions about opening hours and take a message for anything else.",
1719
+ tools: { send_sms: true } # snake_case or camelCase keys
1720
+ )
1721
+ puts agent.can_send_sms? # true once it holds its scoped sending key
1722
+ client.voice.agents.update(agent.id, greeting: "Thanks for calling Acme. How can I help today?")
1723
+ client.voice.agents.list.each { |a| puts "#{a.name}: #{a.calls_handled} calls" }
1724
+
1725
+ # Switching voice on changes how real calls to the number are answered.
1726
+ client.voice.numbers.update("+15125550123", voice_enabled: true, voice_mode: "agent", agent_id: agent.id)
1727
+ client.voice.numbers.update("+15125550123", voice_mode: "ring_dashboard") # ring the team instead
1728
+ client.voice.numbers.update("+15125550123", voice_enabled: false) # switch voice off
1729
+
1730
+ # An agent that answers a number can't be deleted until the number is moved.
1731
+ begin
1732
+ client.voice.agents.delete(agent.id)
1733
+ rescue Sendly::APIError => e
1734
+ raise unless e.response_body&.dig("error") == "agent_in_use"
1735
+
1736
+ puts "Still answers #{e.response_body['numbers'].join(', ')}"
1737
+ end
1738
+ ```
1739
+
1740
+ A mode alone is enough: `voice_mode: "agent"` or `"ring_dashboard"` switches
1741
+ voice on, so it can fail the way switching on does, and `voice_mode: "none"`
1742
+ switches it off. `voice_enabled: false` wins over any mode, and
1743
+ `voice_enabled: true` with `"none"` answers in `"ring_dashboard"` mode.
1744
+
1745
+ Configuration errors map the same way: `Sendly::NotFoundError` for
1746
+ `number_not_found` and `agent_not_found`; `Sendly::ValidationError` for
1747
+ `invalid_request` (for example an emergency address field that is not a
1748
+ string), `invalid_voice_mode`, `agent_required`,
1749
+ `e911_not_applicable` and `invalid_address` (a 422 `invalid_address` means
1750
+ the address could not be validated, and `e.response_body["suggested"]` holds
1751
+ a corrected address when one was found); `Sendly::APIError` with
1752
+ `status_code` 409 for `agent_disabled`, `agent_limit` (20 agents per
1753
+ workspace) and `agent_in_use`; and `Sendly::ServerError` for 502
1754
+ `voice_attach_failed` and `carrier_refused` and 503 `voice_unavailable`,
1755
+ raised after the client has already retried the 5xx on its own. Not every
1756
+ `carrier_refused` is worth retrying: when the message says the number
1757
+ couldn't be found for emergency registration, retrying won't help, so
1758
+ contact support. When it says the address couldn't be registered or
1759
+ emergency calling couldn't be switched on, try again later. Every API error
1760
+ keeps the parsed body on `e.response_body`.
1256
1761
 
1257
1762
  ## Error Handling
1258
1763
 
1259
1764
  ```ruby
1260
1765
  begin
1261
1766
  message = client.messages.send(
1262
- to: "+15551234567",
1767
+ to: "+15125550123",
1263
1768
  text: "Hello!"
1264
1769
  )
1265
1770
  rescue Sendly::AuthenticationError => e
1266
1771
  puts "Invalid API key"
1267
1772
  rescue Sendly::RateLimitError => e
1268
- puts "Rate limited, retry after #{e.retry_after} seconds"
1773
+ if e.response_body&.dig("error") == "too_many_failed_key_attempts"
1774
+ # Repeated wrong API keys from this address locked it out for up to
1775
+ # 5 minutes. Fix the key; do not retry until the lockout ends.
1776
+ puts "Locked out for #{e.retry_after}s: check the API key"
1777
+ else
1778
+ puts "Rate limited (#{e.response_body&.dig('error')}), retry after #{e.retry_after} seconds"
1779
+ end
1269
1780
  rescue Sendly::InsufficientCreditsError => e
1270
1781
  puts "Add more credits to your account"
1271
1782
  rescue Sendly::ValidationError => e
@@ -1275,48 +1786,152 @@ rescue Sendly::NotFoundError => e
1275
1786
  rescue Sendly::NetworkError => e
1276
1787
  puts "Network error: #{e.message}"
1277
1788
  rescue Sendly::Error => e
1278
- puts "Error: #{e.message} (#{e.code})"
1789
+ puts "Error: #{e.message} (#{e.response_body&.dig('error') || e.code})"
1279
1790
  end
1280
1791
  ```
1281
1792
 
1793
+ Every error is a `Sendly::Error`, which carries `message`, `code`,
1794
+ `status_code`, `details` and `response_body` — the parsed JSON body of the
1795
+ API's response, or `nil` for an error raised before the request went out.
1796
+ The API's own error code is `e.response_body["error"]`; `code` is the SDK's
1797
+ name for the class (`"VALIDATION_ERROR"`, `"RATE_LIMIT_EXCEEDED"` and so on),
1798
+ except on `APIError`, where it is the body's `"code"` when there is one. Some
1799
+ refusals put more in that body than a message: a 409 `agent_in_use` lists the
1800
+ numbers under `"numbers"`, a 422 `invalid_address` carries a corrected
1801
+ address under `"suggested"`.
1802
+
1803
+ Some requests are refused before they are sent, with `response_body` `nil`:
1804
+ an argument the method checks itself raises `ArgumentError` or
1805
+ `Sendly::ValidationError`, and any ID that is empty, `"."` or `".."` raises
1806
+ `Sendly::ValidationError`, so an ID can never send a request to a different
1807
+ endpoint.
1808
+
1809
+ | Class | Raised for |
1810
+ |-------|-----------|
1811
+ | `Sendly::AuthenticationError` | 401, and a missing or malformed API key at construction |
1812
+ | `Sendly::ValidationError` | 400 and 422; `field_errors` holds the API's `errors` list. `status_code` reads 400 for both, so tell them apart by `response_body["error"]` |
1813
+ | `Sendly::InsufficientCreditsError` | 402 |
1814
+ | `Sendly::NotFoundError` | 404, including a feature that is not enabled for you |
1815
+ | `Sendly::RateLimitError` | 429; `retry_after` in seconds when the API sent one |
1816
+ | `Sendly::ServerError` | 5xx |
1817
+ | `Sendly::APIError` | any other non-2xx status |
1818
+ | `Sendly::NetworkError` | connection refused or reset, DNS failure |
1819
+ | `Sendly::TimeoutError` | a timeout (subclass of `NetworkError`) |
1820
+ | `Sendly::WebhookSignatureError` | a webhook signature that does not verify, or a malformed payload |
1821
+
1822
+ `ServerError` and `APIError` are siblings — both inherit `Sendly::Error`
1823
+ directly — so rescuing `APIError` does **not** catch a 5xx.
1824
+
1825
+ ### Retries and rate limits
1826
+
1827
+ The client retries two kinds of response by itself, up to `max_retries`
1828
+ (default 3), and sends every retry with the same idempotency key, generated
1829
+ or yours:
1830
+
1831
+ - any 5xx, with exponential backoff (2, 4, 8... seconds);
1832
+ - a 429 that waiting can fix, when its wait is 60 seconds or less: an
1833
+ ordinary `rate_limit_exceeded`, the per-minute `provision_rate_limit` from
1834
+ enterprise workspace provisioning (120 provisioning requests a minute), a
1835
+ 429 with no `error`
1836
+ code, or `too_many_concurrent_verifications` (too many first-time API key
1837
+ checks at once). The wait is read from the `Retry-After` header first, then
1838
+ from the body's `retryAfter`, so a 429 from a proxy that sends only the
1839
+ header is waited out too.
1840
+
1841
+ Every other 429 is raised at once as a `Sendly::RateLimitError`, with the
1842
+ API's code in `e.response_body["error"]` and the wait in `e.retry_after`:
1843
+
1844
+ - `too_many_failed_key_attempts`: repeated wrong API keys from one address
1845
+ locked it out for up to 5 minutes. Fix the key, then wait, since until the
1846
+ lockout ends the right key can be refused too.
1847
+ - `rate_limit_exceeded` from `verify.send` and `verify.resend` at the
1848
+ per-phone limit (5 codes per 10 minutes) or the daily limit (20 a day),
1849
+ while more than a minute of that window remains, so the call raises instead
1850
+ of blocking and then sending a code nobody is waiting for. In the window's
1851
+ last minute the wait is 60 seconds or less, and the client waits it out and
1852
+ sends the code like any other rate limit.
1853
+ - the hourly `provision_rate_limit` (1,000 provisioning requests an hour; a
1854
+ `provision_bulk` call counts as one), so you can pace provisioning. It too
1855
+ is waited out once 60 seconds or less of the hour remain.
1856
+ - `whatsapp_signup_limit_reached` and `daily_call_limit`.
1857
+
1858
+ A 429 with no wait at all is raised at once too. Timeouts and network errors
1859
+ are never retried: retry those yourself, with your own idempotency key.
1860
+ `enterprise.upload_verification_document`, `business_upgrade.start` and
1861
+ `business_upgrade.resubmit` send their uploads once, so any 429 or 5xx there
1862
+ is raised at once.
1863
+
1282
1864
  ## Message Object
1283
1865
 
1284
1866
  ```ruby
1285
1867
  message.id # Unique identifier
1286
1868
  message.to # Recipient phone number
1869
+ message.from # Sender ID or number (the default sender when you omit from:; "SENDLY-TEST" when simulated)
1287
1870
  message.text # Message content
1288
- message.status # queued, sending, sent, delivered, failed
1871
+ message.status # see Message Status below
1872
+ message.direction # "outbound" or "inbound"
1873
+ message.segments # Number of SMS segments
1289
1874
  message.credits_used # Credits consumed
1290
- message.created_at # Creation time
1291
- message.updated_at # Last update time
1292
- message.delivered_at # Delivery time (if delivered)
1875
+ message.is_sandbox # true for a simulated message (read it via messages.get or list; a send response leaves it false)
1876
+ message.sender_type # "number_pool", "alphanumeric" or "explicit" (live sends only)
1877
+ message.created_at # Creation time (Time)
1878
+ message.delivered_at # Delivery time (Time, if delivered)
1879
+ message.error # Error message (if failed)
1293
1880
  message.error_code # Error code (if failed)
1294
- message.error_message # Error message (if failed)
1881
+ message.retry_count # Delivery retry attempts
1882
+ message.metadata # The metadata Hash you attached
1883
+ message.media_urls # Attachments of an MMS; [] when there are none
1884
+ message.message_format # "sms", "mms", "rcs" or "whatsapp" ("sms" when the API does not say)
1885
+ message.ai_metadata # AI classification, on inbound messages
1886
+ message.warning # Warning about the send, when the API sent one
1887
+ message.sender_note # Note about sender behaviour, when the API sent one
1295
1888
 
1296
1889
  # Helper methods
1297
1890
  message.delivered? # => true/false
1298
1891
  message.failed? # => true/false
1299
1892
  message.pending? # => true/false
1893
+ message.to_h # => Hash of the above
1300
1894
  ```
1301
1895
 
1896
+ There is no `updated_at` and no `error_message`; the failure text is on
1897
+ `error`. `Sendly::Message` is what `messages.send`, `messages.get` and
1898
+ `messages.list` return for SMS — a WhatsApp send returns a
1899
+ `Sendly::WhatsAppMessage`, an RCS send a `Sendly::RcsMessage`, and a group
1900
+ send a `Sendly::GroupMessage`, each with its own channel fields.
1901
+
1302
1902
  ## Message Status
1303
1903
 
1904
+ `Sendly::Message::STATUSES` is the vocabulary:
1905
+
1304
1906
  | Status | Description |
1305
1907
  |--------|-------------|
1306
1908
  | `queued` | Message is queued for delivery |
1307
- | `sending` | Message is being sent |
1308
1909
  | `sent` | Message was sent to carrier |
1309
1910
  | `delivered` | Message was delivered |
1310
1911
  | `failed` | Message delivery failed |
1912
+ | `bounced` | Invalid recipient, or the carrier rejected it |
1913
+ | `retrying` | A failed send is being retried |
1914
+
1915
+ There is no `sending` status. WhatsApp and RCS additionally report reads,
1916
+ as the `message.read` webhook (`Sendly::Webhooks::EVENT_MESSAGE_READ`);
1917
+ SMS has no read receipts.
1311
1918
 
1312
1919
  ## Pricing Tiers
1313
1920
 
1921
+ Per segment, at 1 credit = $0.01.
1922
+
1314
1923
  | Tier | Countries | Credits per SMS |
1315
1924
  |------|-----------|-----------------|
1316
1925
  | Domestic | US, CA | 2 |
1317
- | Tier 1 | GB, PL, IN, etc. | 8 |
1318
- | Tier 2 | FR, JP, AU, etc. | 12 |
1319
- | Tier 3 | DE, IT, MX, etc. | 16 |
1926
+ | Tier 1 | GB, AU, PL, PT, SE, DK, etc. | 8 |
1927
+ | Tier 2 | FR, JP, IN, IT, etc. | 12 |
1928
+ | Tier 3 | DE, MX, etc. | 16 |
1929
+ | Tier 4 | GE, MU, ME, AD, etc. | 24 |
1930
+ | Tier 5 | IL, SI, etc. | 48 |
1931
+
1932
+ A multi-segment message costs its tier's rate per segment. Enterprise
1933
+ accounts can have per-country or per-tier rates overridden, in which case
1934
+ the override applies instead.
1320
1935
 
1321
1936
  ## Sandbox Testing
1322
1937
 
@@ -1333,14 +1948,14 @@ Use test API keys (`sk_test_v1_xxx`) with these test numbers:
1333
1948
 
1334
1949
  ## Enterprise
1335
1950
 
1336
- 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_*`).
1951
+ The Enterprise API lets you programmatically manage workspaces, verification, credits, and API keys for multi-tenant platforms. It requires an enterprise master key — an ordinary live key (`sk_live_v1_…`) that has been marked as your organization's master key in the dashboard; what distinguishes it is the flag on the key, not the prefix. A non-master key is refused with 403 `enterprise_required`, and a master key whose enterprise account is inactive with 403 `enterprise_inactive`. Master keys also get the higher rate limit of 3,000 requests a minute.
1337
1952
 
1338
1953
  ### Quick Provision
1339
1954
 
1340
1955
  Create a fully configured workspace in a single call:
1341
1956
 
1342
1957
  ```ruby
1343
- client = Sendly::Client.new(api_key: "sk_live_v1_master_YOUR_KEY")
1958
+ client = Sendly::Client.new(api_key: "sk_live_v1_your_master_key")
1344
1959
 
1345
1960
  result = client.enterprise.provision(
1346
1961
  name: "Acme Insurance - Austin",
@@ -1379,9 +1994,12 @@ client.enterprise.workspaces.delete("ws_xxx")
1379
1994
  client.enterprise.workspaces.transfer_credits("ws_dest",
1380
1995
  source_workspace_id: "ws_source", amount: 5000)
1381
1996
 
1997
+ # type: is "test" (the default) or "live"; scopes: defaults to every scope.
1998
+ # A name is required: a missing or empty one raises ArgumentError.
1382
1999
  key = client.enterprise.workspaces.create_key("ws_xxx",
1383
- name: "Production", type: "live")
1384
- puts key["key"]
2000
+ name: "Production", type: "live", scopes: ["sms:send", "sms:read"])
2001
+ puts key["key"] # shown only once
2002
+ puts key["scopes"].inspect
1385
2003
 
1386
2004
  client.enterprise.workspaces.revoke_key("ws_xxx", "key_abc")
1387
2005
  ```
@@ -1389,12 +2007,73 @@ client.enterprise.workspaces.revoke_key("ws_xxx", "key_abc")
1389
2007
  ### Webhooks & Analytics
1390
2008
 
1391
2009
  ```ruby
1392
- client.enterprise.webhooks.set(url: "https://yourapp.com/webhooks")
2010
+ client.enterprise.webhooks.set(url: "https://hooks.acme.example/enterprise")
2011
+ client.enterprise.webhooks.test
2012
+ client.enterprise.webhooks.rotate_secret
2013
+
1393
2014
  overview = client.enterprise.analytics.overview
1394
2015
  messages = client.enterprise.analytics.messages(period: "30d")
1395
2016
  delivery = client.enterprise.analytics.delivery
2017
+ credits = client.enterprise.analytics.credits(period: "30d")
1396
2018
  ```
1397
2019
 
2020
+ ### The rest of the enterprise surface
2021
+
2022
+ ```ruby
2023
+ # Verification, per workspace. A first submission needs business_name, website,
2024
+ # address, contact, use_case, use_case_summary, sample_messages and
2025
+ # opt_in_workflow. A resubmit after a rejection may send only the top-level
2026
+ # fields that change, but an address or contact Hash replaces the stored one
2027
+ # whole, so send every key of it.
2028
+ client.enterprise.workspaces.submit_verification("ws_xxx", **verification_fields)
2029
+ client.enterprise.workspaces.resubmit_verification("ws_xxx",
2030
+ contact: { firstName: "Sam", lastName: "Rivera", email: "new@acme.example", phone: "+15555550100" })
2031
+ # Share the source workspace's verification and sending number (nothing is
2032
+ # bought), or copy only its business details and buy the workspace its own
2033
+ # toll-free number; the response then has "newNumber" => true.
2034
+ client.enterprise.workspaces.inherit_verification("ws_xxx", source_workspace_id: "ws_verified")
2035
+ client.enterprise.workspaces.inherit_verification("ws_other", source_workspace_id: "ws_verified",
2036
+ purchase_new_number: true)
2037
+ client.enterprise.workspaces.get_verification("ws_xxx")
2038
+ client.enterprise.upload_verification_document("./CP-575.pdf", workspace_id: "ws_xxx")
2039
+
2040
+ # Hosted opt-in pages, and a generated business page
2041
+ client.enterprise.workspaces.list_opt_in_pages("ws_xxx")
2042
+ client.enterprise.workspaces.create_opt_in_page("ws_xxx", business_name: "Acme LLC")
2043
+ client.enterprise.workspaces.update_opt_in_page("ws_xxx", "page_x", header_color: "#5B3A29")
2044
+ client.enterprise.workspaces.delete_opt_in_page("ws_xxx", "page_x")
2045
+ client.enterprise.workspaces.set_custom_domain("ws_xxx", "page_x", domain: "sms.acme.example")
2046
+ client.enterprise.generate_business_page(business_name: "Acme LLC")
2047
+
2048
+ # Members: invite into a workspace, list and cancel invitations
2049
+ client.enterprise.workspaces.send_invitation("ws_xxx", email: "sam@acme.example", role: "member")
2050
+ client.enterprise.workspaces.list_invitations("ws_xxx")
2051
+ client.enterprise.workspaces.cancel_invitation("ws_xxx", "inv_x")
2052
+
2053
+ # Suspend / resume, quotas, per-workspace webhooks, bulk provisioning
2054
+ client.enterprise.workspaces.suspend("ws_xxx", reason: "non-payment")
2055
+ client.enterprise.workspaces.resume("ws_xxx")
2056
+ client.enterprise.workspaces.get_quota("ws_xxx")
2057
+ client.enterprise.workspaces.set_quota("ws_xxx", monthly_message_quota: 50_000)
2058
+ client.enterprise.workspaces.set_webhook("ws_xxx", url: "https://yourapp.example/hooks")
2059
+ client.enterprise.workspaces.list_webhooks("ws_xxx")
2060
+ client.enterprise.workspaces.test_webhook("ws_xxx")
2061
+ client.enterprise.workspaces.delete_webhooks("ws_xxx", webhook_id: "whk_xxx")
2062
+ client.enterprise.workspaces.provision_bulk([{ name: "Acme Austin" }]) # max 100
2063
+ client.enterprise.workspaces.get_credits("ws_xxx")
2064
+ client.enterprise.workspaces.list_keys("ws_xxx")
2065
+
2066
+ # Enterprise-level account, credits and billing
2067
+ client.enterprise.get_account
2068
+ client.enterprise.credits.get
2069
+ client.enterprise.credits.deposit(amount: 100_000, description: "Q4 top-up")
2070
+ client.enterprise.settings.get_auto_top_up
2071
+ client.enterprise.settings.update_auto_top_up(enabled: true, threshold: 1000, amount: 10_000)
2072
+ client.enterprise.billing.get_breakdown(period: "30d")
2073
+ ```
2074
+
2075
+ These return the API's parsed Hash rather than typed objects.
2076
+
1398
2077
  Full enterprise docs: [sendly.live/docs/enterprise](https://sendly.live/docs/enterprise)
1399
2078
 
1400
2079
  ---