sendly 4.2.0 → 4.4.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,121 @@ 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"]
1318
+ )
1319
+
1320
+ # Fall back to SMS from the same number when WhatsApp can't deliver: the
1321
+ # window is closed, WhatsApp refuses it, or reports it undeliverable later.
1322
+ # The SMS is billed as an SMS. The number must already be able to send SMS to
1323
+ # the recipient (US and Canada), or this raises 403 sms_fallback_unavailable;
1324
+ # marketing and authentication templates, and media without a caption, raise
1325
+ # 422 sms_fallback_not_supported.
1326
+ message = client.messages.send(
1327
+ channel: "whatsapp",
1328
+ to: "+15555550100",
1329
+ from: "+15125550123",
1330
+ text: "Your table is ready!",
1331
+ fallback_to_sms: true
994
1332
  )
1333
+ message.fallback.status # "pending", "sent", "failed", "unavailable" or "skipped"
1334
+ message.fallback.reason # "window_closed", "whatsapp_refused" or "whatsapp_failed"
1335
+ message.fell_back? # true when the SMS went (message.status is then "failed")
1336
+ sms = client.messages.get(message.fallback.message_id) if message.fallback.message_id
1337
+ sms&.fallback_of # the WhatsApp message's id
995
1338
  ```
996
1339
 
1340
+ WhatsApp errors map onto the usual classes:
1341
+
1342
+ - `signup.create` raises `Sendly::ServerError` for 503 `whatsapp_unavailable`
1343
+ while WhatsApp connections are unavailable. Only signup returns it; no send
1344
+ does. Nothing is charged, the response carries a `Retry-After: 3600` header,
1345
+ and `e.response_body["retryAfter"]` (3600) says how many seconds to wait; the client
1346
+ retries it like any 5xx before raising. After 5 failed, charged signups in
1347
+ 24 hours it raises `Sendly::RateLimitError` for 429
1348
+ `whatsapp_signup_limit_reached`, which is not retried: try again the next
1349
+ day.
1350
+ - A send outside an open window raises `Sendly::ValidationError` (422
1351
+ `whatsapp_window_closed`); send a template instead.
1352
+ - `whatsapp_send_failed` is a `Sendly::ValidationError` (422) when WhatsApp
1353
+ refused the message, which is final (cached under the idempotency key and
1354
+ replayed for 24 hours), and a `Sendly::ServerError` (502) when the message
1355
+ provably never reached the carrier, so it was not sent and is safe to send
1356
+ again; the 502 is never cached and the client retries it first under the
1357
+ same idempotency key. Neither is charged.
1358
+ - `whatsapp_send_unconfirmed` is a `Sendly::APIError` with `status_code` 409:
1359
+ the outcome is unknown. The message was marked failed and refunded but may
1360
+ still be delivered, so check before sending it again (it could arrive
1361
+ twice). It is not retried automatically, and it is cached under the
1362
+ idempotency key.
1363
+ - A send while WhatsApp is off for the key's owner gets 403
1364
+ `whatsapp_not_enabled`; a test key on a send or a write gets 403
1365
+ `whatsapp_requires_live_key`.
1366
+ - `templates.create` for a sender that isn't connected raises
1367
+ `Sendly::NotFoundError` (404 `whatsapp_sender_not_connected`), checked
1368
+ before anything else.
1369
+ - A template the API refuses for its content raises `Sendly::ValidationError`
1370
+ (400) with a `template_*` code in `e.response_body["error"]`:
1371
+ `template_category_invalid` (category missing or not one of the three),
1372
+ `template_authentication_otp_button_required`,
1373
+ `template_authentication_no_links` (a link in the body or a URL button on an
1374
+ authentication template) or `template_header_variable_unsupported`. A
1375
+ marketing template without an opt-out button only gets a warning. `template_already_exists`,
1376
+ `template_name_locked` and `template_not_editable` are a `Sendly::APIError`
1377
+ with `status_code` 409, and `template_not_found` is a
1378
+ `Sendly::NotFoundError`.
1379
+ - Adding a number by code: `signup.create` with `business_account_id:` raises
1380
+ `Sendly::NotFoundError` for 404 `whatsapp_business_account_not_found` (the
1381
+ account isn't connected in this workspace), `Sendly::ValidationError` for
1382
+ 400 `display_name_required` and for 422 `whatsapp_verification_start_failed`
1383
+ (WhatsApp refused to verify the number; final), `Sendly::APIError` (409) for
1384
+ `whatsapp_signup_in_progress` and `whatsapp_already_enabled`, and
1385
+ `Sendly::ServerError` for 502 `whatsapp_verification_start_failed` (WhatsApp
1386
+ couldn't start verifying the number; start again). Either failure fails the
1387
+ session and refunds its fee. With `business_account_id:` the client never
1388
+ retries a 5xx, a timeout or a dropped connection, because each retry would
1389
+ start a new charged session. A
1390
+ Facebook signup for a number
1391
+ that is being added by code gets 409 `whatsapp_verification_in_progress`,
1392
+ with that session's id in `e.response_body["id"]`.
1393
+ - `signup.verify` raises `Sendly::ValidationError` for 400
1394
+ `invalid_verification_code` (not 6 digits) and 422
1395
+ `whatsapp_verification_code_invalid` (a wrong code, with
1396
+ `e.response_body["attemptsRemaining"]`); `Sendly::APIError` (409) for
1397
+ `whatsapp_verification_failed` (after 5 wrong codes the session fails and
1398
+ the fee is refunded), `whatsapp_verification_busy` (try again) and
1399
+ `signup_not_active`; and `Sendly::ServerError` for 502
1400
+ `whatsapp_verification_unavailable` (the attempt isn't counted) and
1401
+ `whatsapp_activation_pending` (the code was accepted but connecting didn't
1402
+ finish; check back shortly with `signup.get`). The client never retries a
1403
+ 5xx, a timeout or a dropped connection from `signup.verify`: each
1404
+ submission uses one of the 5 attempts.
1405
+ `signup.resend` is retried like any other call.
1406
+ - `signup.resend` raises `Sendly::RateLimitError` for 429
1407
+ `whatsapp_verification_resend_too_soon` at once, with the seconds to wait in
1408
+ `e.retry_after`, and `whatsapp_verification_resend_failed` as a
1409
+ `Sendly::ValidationError` (422) or a `Sendly::ServerError` (502).
1410
+ - `senders.upload_profile_photo` raises `Sendly::ValidationError` for 400
1411
+ `whatsapp_profile_photo_invalid` (not a JPEG or PNG) and `file_required`,
1412
+ and `Sendly::APIError` with `status_code` 413 for
1413
+ `whatsapp_profile_photo_too_large`. The photo methods raise
1414
+ `Sendly::ServerError` for 502 `whatsapp_profile_update_failed`, and the
1415
+ conversational component methods for 502
1416
+ `whatsapp_conversational_components_fetch_failed` and
1417
+ `whatsapp_conversational_components_update_failed`; an invalid list is a
1418
+ `Sendly::ValidationError` (400 `invalid_request`) with a message naming the
1419
+ problem. The client never retries a 5xx, a timeout or a dropped connection
1420
+ from `senders.upload_profile_photo`.
1421
+ - `senders.set_calling` raises `Sendly::APIError` (409) for `voice_not_enabled`
1422
+ (switch voice on for the number first), `Sendly::ValidationError` for 422
1423
+ `whatsapp_calling_unavailable` (Meta only allows calling once the account may
1424
+ message at least 2,000 people a day and the display name is approved), and
1425
+ `Sendly::ServerError` for 502 `whatsapp_calling_update_failed`.
1426
+ - A sender method for a number that isn't connected raises
1427
+ `Sendly::NotFoundError` (404 `whatsapp_sender_not_connected`).
1428
+
997
1429
  ## RCS
998
1430
 
999
1431
  Send branded rich messages — text with suggested replies and actions, or
@@ -1042,7 +1474,7 @@ client.rcs.brands.update(brand.id,
1042
1474
  display_name: "Acme Coffee",
1043
1475
  legal_entity_type: "LIMITED_LIABILITY_COMPANY",
1044
1476
  contact: { first_name: "Sam", last_name: "Lee", email: "sam@acme.example",
1045
- phone_number: "+15551234567" }
1477
+ phone_number: "+15125550123" }
1046
1478
  )
1047
1479
 
1048
1480
  # Draft the agent. Media must be public https URLs.
@@ -1057,7 +1489,7 @@ agent = client.rcs.agents.create(
1057
1489
  brand_color: "#5B3A29",
1058
1490
  privacy_policy_url: "https://acme.example/privacy",
1059
1491
  terms_and_conditions_url: "https://acme.example/terms",
1060
- phone_number: { number: "+15551234567", label: "Support" }
1492
+ phone_number: { number: "+15125550123", label: "Support" }
1061
1493
  }
1062
1494
  )
1063
1495
 
@@ -1076,7 +1508,7 @@ end
1076
1508
  # Once the agent is in testing, invite devices, describe the campaign,
1077
1509
  # then request launch
1078
1510
  client.rcs.agents.set_test_devices(agent.id, devices: [
1079
- { phone_number: "+15557654321", label: "Sam's Pixel" }
1511
+ { phone_number: "+15125550123", label: "Sam's Pixel" }
1080
1512
  ])
1081
1513
  client.rcs.agents.update(agent.id,
1082
1514
  campaign: {
@@ -1118,7 +1550,7 @@ client.rcs.agents.list[:agents].each do |a|
1118
1550
  end
1119
1551
 
1120
1552
  # Pre-flight: can this recipient receive RCS?
1121
- capability = client.rcs.capability(to: "+15551234567")
1553
+ capability = client.rcs.capability(to: "+15125550123")
1122
1554
  puts capability.capable? ? "RCS" : "would fall back to SMS"
1123
1555
 
1124
1556
  # Text with suggested replies and actions. Nested suggestion and card
@@ -1126,7 +1558,7 @@ puts capability.capable? ? "RCS" : "would fall back to SMS"
1126
1558
  # API expects.
1127
1559
  message = client.messages.send(
1128
1560
  channel: "rcs",
1129
- to: "+15551234567",
1561
+ to: "+15125550123",
1130
1562
  text: "Your order has shipped! Want live updates?",
1131
1563
  suggestions: [
1132
1564
  { reply: { text: "Yes, notify me", postbackData: "notify_yes" } },
@@ -1148,11 +1580,11 @@ end
1148
1580
  # Rich card (RCS-capable recipients only — cards have no SMS form)
1149
1581
  client.messages.send(
1150
1582
  channel: "rcs",
1151
- to: "+15551234567",
1583
+ to: "+15125550123",
1152
1584
  card: {
1153
1585
  title: "Spring collection",
1154
1586
  description: "New arrivals are in - take a look.",
1155
- mediaUrl: "https://example.com/spring.jpg", # public JPEG, PNG, or GIF
1587
+ mediaUrl: "https://acme.example/spring.jpg", # public JPEG, PNG, or GIF
1156
1588
  orientation: "vertical", # or "horizontal"
1157
1589
  suggestions: [
1158
1590
  { action: { text: "Shop now", postbackData: "shop",
@@ -1166,7 +1598,7 @@ client.messages.send(
1166
1598
  # receive RCS
1167
1599
  client.messages.send(
1168
1600
  channel: "rcs",
1169
- to: "+15551234567",
1601
+ to: "+15125550123",
1170
1602
  text: "RCS or nothing",
1171
1603
  fallback_to_sms: false
1172
1604
  )
@@ -1202,9 +1634,9 @@ and `hangup` need `calls:write` and a live key.
1202
1634
  # Place a call. Returns at once with the call ringing; the agent greets the
1203
1635
  # callee when they answer and uses `context` for this call only.
1204
1636
  call = client.calls.create(
1205
- to: "+15555550123",
1637
+ to: "+15125550123",
1206
1638
  agent_id: "3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b",
1207
- from: "+15555550188", # optional when you have one voice number
1639
+ from: "+15555550100", # optional when you have one voice number
1208
1640
  context: "Confirm the 3pm appointment on Tuesday.",
1209
1641
  metadata: { "crmId" => "lead_8812" } # up to 20 string pairs, echoed everywhere
1210
1642
  )
@@ -1243,7 +1675,8 @@ when the balance cannot cover one minute at the agent rate;
1243
1675
  `Sendly::NotFoundError` for `voice_not_enabled`, `outbound_calls_not_enabled`,
1244
1676
  `agent_not_found`, `number_not_found` and `call_not_found`;
1245
1677
  `Sendly::ValidationError` for `invalid_number`, `destination_not_supported`,
1246
- `agent_required`, `invalid_metadata` and `from_number_required`;
1678
+ `agent_required`, `invalid_metadata`, `from_number_required` and
1679
+ `from_number_not_supported` (a `from` that is not a US or Canadian number);
1247
1680
  `Sendly::RateLimitError` for `daily_call_limit`; `Sendly::APIError` with
1248
1681
  `status_code` 428 for `e911_required` (register an emergency address for the
1249
1682
  number), 409 for `agent_disabled`, `no_voice_number` and `lines_busy` (retry
@@ -1253,9 +1686,15 @@ shortly), or 403 for `live_key_required` and a key missing the scope; and
1253
1686
  The full list is `Sendly::Call::ERROR_CODES`; the hangup vocabulary is
1254
1687
  `Sendly::Call::HANGUP_CLASSES`.
1255
1688
 
1689
+ `call.channel` is where the call took place: `"phone"`, `"whatsapp"` (a
1690
+ WhatsApp call, for example one placed from the dashboard) or `"browser"`
1691
+ (`Sendly::Call::CHANNELS`); a value this SDK predates comes through unchanged.
1692
+ An inbound WhatsApp call can read `"phone"` until the carrier labels it.
1693
+
1256
1694
  `call.started`, `call.completed` and `call.recording.ready` webhooks carry
1257
- the same object in snake_case (`handled_by`, `hangup_class`, `billing`,
1258
- `metadata`, ...); `Sendly::Call.new(event.raw_object)` reads it.
1695
+ the same object in snake_case (`channel`, `handled_by`, `hangup_class`,
1696
+ `billing`, `metadata`, ...); read it from `event.data`, for example
1697
+ `event.data[:channel]`.
1259
1698
 
1260
1699
  ### Configure voice
1261
1700
 
@@ -1272,14 +1711,14 @@ can change settings, and managing agents a role that can manage API keys
1272
1711
  client.voice.numbers.list.each do |n|
1273
1712
  puts "#{n.phone_number} #{n.voice_mode} #{n.emergency_address&.status || 'no emergency address'}"
1274
1713
  end
1275
- number = client.voice.numbers.get("+15555550188")
1714
+ number = client.voice.numbers.get("+15125550123")
1276
1715
  puts number.rate_per_minute.agent # credits a minute when an agent answers
1277
1716
 
1278
1717
  # A US or Canadian number needs an emergency address before it can place
1279
1718
  # calls. The first registration adds $1.50 a month to the number;
1280
1719
  # registering again replaces the address without a second charge.
1281
1720
  number = client.voice.numbers.register_emergency_address(
1282
- "+15555550188",
1721
+ "+15125550123",
1283
1722
  street: "500 Example Ave",
1284
1723
  unit: "Suite 2",
1285
1724
  city: "Austin",
@@ -1303,9 +1742,9 @@ client.voice.agents.update(agent.id, greeting: "Thanks for calling Acme. How can
1303
1742
  client.voice.agents.list.each { |a| puts "#{a.name}: #{a.calls_handled} calls" }
1304
1743
 
1305
1744
  # Switching voice on changes how real calls to the number are answered.
1306
- client.voice.numbers.update("+15555550188", voice_enabled: true, voice_mode: "agent", agent_id: agent.id)
1307
- client.voice.numbers.update("+15555550188", voice_mode: "ring_dashboard") # ring the team instead
1308
- client.voice.numbers.update("+15555550188", voice_enabled: false) # switch voice off
1745
+ client.voice.numbers.update("+15125550123", voice_enabled: true, voice_mode: "agent", agent_id: agent.id)
1746
+ client.voice.numbers.update("+15125550123", voice_mode: "ring_dashboard") # ring the team instead
1747
+ client.voice.numbers.update("+15125550123", voice_enabled: false) # switch voice off
1309
1748
 
1310
1749
  # An agent that answers a number can't be deleted until the number is moved.
1311
1750
  begin
@@ -1344,13 +1783,19 @@ keeps the parsed body on `e.response_body`.
1344
1783
  ```ruby
1345
1784
  begin
1346
1785
  message = client.messages.send(
1347
- to: "+15551234567",
1786
+ to: "+15125550123",
1348
1787
  text: "Hello!"
1349
1788
  )
1350
1789
  rescue Sendly::AuthenticationError => e
1351
1790
  puts "Invalid API key"
1352
1791
  rescue Sendly::RateLimitError => e
1353
- puts "Rate limited, retry after #{e.retry_after} seconds"
1792
+ if e.code == "too_many_failed_key_attempts"
1793
+ # Repeated wrong API keys from this address locked it out for up to
1794
+ # 5 minutes. Fix the key; do not retry until the lockout ends.
1795
+ puts "Locked out for #{e.retry_after}s: check the API key"
1796
+ else
1797
+ puts "Rate limited (#{e.code}), retry after #{e.retry_after} seconds"
1798
+ end
1354
1799
  rescue Sendly::InsufficientCreditsError => e
1355
1800
  puts "Add more credits to your account"
1356
1801
  rescue Sendly::ValidationError => e
@@ -1364,44 +1809,149 @@ rescue Sendly::Error => e
1364
1809
  end
1365
1810
  ```
1366
1811
 
1812
+ Every error is a `Sendly::Error`, which carries `message`, `code`,
1813
+ `status_code`, `details` and `response_body` — the parsed JSON body of the
1814
+ API's response, or `nil` for an error raised before the request went out.
1815
+ `code` is the API's own error code, such as `"whatsapp_window_closed"` or
1816
+ `"sms_fallback_unavailable"` (the body's `"error"`), so refusals with the same
1817
+ status can be told apart. When the body names no code, `code` is the SDK's
1818
+ name for the class (`"VALIDATION_ERROR"`, `"RATE_LIMIT_EXCEEDED"` and so on).
1819
+ Some refusals put more in that body than a message: a 409 `agent_in_use` lists the
1820
+ numbers under `"numbers"`, a 422 `invalid_address` carries a corrected
1821
+ address under `"suggested"`.
1822
+
1823
+ Some requests are refused before they are sent, with `response_body` `nil`:
1824
+ an argument the method checks itself raises `ArgumentError` or
1825
+ `Sendly::ValidationError`, and any ID that is empty, `"."` or `".."` raises
1826
+ `Sendly::ValidationError`, so an ID can never send a request to a different
1827
+ endpoint.
1828
+
1829
+ | Class | Raised for |
1830
+ |-------|-----------|
1831
+ | `Sendly::AuthenticationError` | 401, and a missing or malformed API key at construction |
1832
+ | `Sendly::ValidationError` | 400 and 422; `field_errors` holds the API's `errors` list. `status_code` reads 400 for both, so tell them apart by `code` |
1833
+ | `Sendly::InsufficientCreditsError` | 402 |
1834
+ | `Sendly::NotFoundError` | 404, including a feature that is not enabled for you |
1835
+ | `Sendly::RateLimitError` | 429; `retry_after` in seconds when the API sent one |
1836
+ | `Sendly::ServerError` | 5xx |
1837
+ | `Sendly::APIError` | any other non-2xx status |
1838
+ | `Sendly::NetworkError` | connection refused or reset, DNS failure |
1839
+ | `Sendly::TimeoutError` | a timeout (subclass of `NetworkError`) |
1840
+ | `Sendly::WebhookSignatureError` | a webhook signature that does not verify, or a malformed payload |
1841
+
1842
+ `ServerError` and `APIError` are siblings — both inherit `Sendly::Error`
1843
+ directly — so rescuing `APIError` does **not** catch a 5xx.
1844
+
1845
+ ### Retries and rate limits
1846
+
1847
+ The client retries two kinds of response by itself, up to `max_retries`
1848
+ (default 3), and sends every retry with the same idempotency key, generated
1849
+ or yours:
1850
+
1851
+ - any 5xx, with exponential backoff (2, 4, 8... seconds);
1852
+ - a 429 that waiting can fix, when its wait is 60 seconds or less: an
1853
+ ordinary `rate_limit_exceeded`, the per-minute `provision_rate_limit` from
1854
+ enterprise workspace provisioning (120 provisioning requests a minute), a
1855
+ 429 with no `error`
1856
+ code, or `too_many_concurrent_verifications` (too many first-time API key
1857
+ checks at once). The wait is read from the `Retry-After` header first, then
1858
+ from the body's `retryAfter`, so a 429 from a proxy that sends only the
1859
+ header is waited out too.
1860
+
1861
+ Every other 429 is raised at once as a `Sendly::RateLimitError`, with the
1862
+ API's code in `e.response_body["error"]` and the wait in `e.retry_after`:
1863
+
1864
+ - `too_many_failed_key_attempts`: repeated wrong API keys from one address
1865
+ locked it out for up to 5 minutes. Fix the key, then wait, since until the
1866
+ lockout ends the right key can be refused too.
1867
+ - `rate_limit_exceeded` from `verify.send` and `verify.resend` at the
1868
+ per-phone limit (5 codes per 10 minutes) or the daily limit (20 a day),
1869
+ while more than a minute of that window remains, so the call raises instead
1870
+ of blocking and then sending a code nobody is waiting for. In the window's
1871
+ last minute the wait is 60 seconds or less, and the client waits it out and
1872
+ sends the code like any other rate limit.
1873
+ - the hourly `provision_rate_limit` (1,000 provisioning requests an hour; a
1874
+ `provision_bulk` call counts as one), so you can pace provisioning. It too
1875
+ is waited out once 60 seconds or less of the hour remain.
1876
+ - `whatsapp_signup_limit_reached` and `daily_call_limit`.
1877
+
1878
+ A 429 with no wait at all is raised at once too. Timeouts and network errors
1879
+ are never retried: retry those yourself, with your own idempotency key.
1880
+ `enterprise.upload_verification_document`, `business_upgrade.start` and
1881
+ `business_upgrade.resubmit` send their uploads once, so any 429 or 5xx there
1882
+ is raised at once.
1883
+
1367
1884
  ## Message Object
1368
1885
 
1369
1886
  ```ruby
1370
1887
  message.id # Unique identifier
1371
1888
  message.to # Recipient phone number
1889
+ message.from # Sender ID or number (the default sender when you omit from:; "SENDLY-TEST" when simulated)
1372
1890
  message.text # Message content
1373
- message.status # queued, sending, sent, delivered, failed
1891
+ message.status # see Message Status below
1892
+ message.direction # "outbound" or "inbound"
1893
+ message.segments # Number of SMS segments
1374
1894
  message.credits_used # Credits consumed
1375
- message.created_at # Creation time
1376
- message.updated_at # Last update time
1377
- message.delivered_at # Delivery time (if delivered)
1895
+ message.is_sandbox # true for a simulated message (read it via messages.get or list; a send response leaves it false)
1896
+ message.sender_type # "number_pool", "alphanumeric" or "explicit" (live sends only)
1897
+ message.created_at # Creation time (Time)
1898
+ message.delivered_at # Delivery time (Time, if delivered)
1899
+ message.error # Error message (if failed)
1378
1900
  message.error_code # Error code (if failed)
1379
- message.error_message # Error message (if failed)
1901
+ message.retry_count # Delivery retry attempts
1902
+ message.metadata # The metadata Hash you attached
1903
+ message.media_urls # Attachments of an MMS; [] when there are none
1904
+ message.message_format # "sms", "mms", "rcs" or "whatsapp" ("sms" when the API does not say)
1905
+ message.ai_metadata # AI classification, on inbound messages
1906
+ message.warning # Warning about the send, when the API sent one
1907
+ message.sender_note # Note about sender behaviour, when the API sent one
1380
1908
 
1381
1909
  # Helper methods
1382
1910
  message.delivered? # => true/false
1383
1911
  message.failed? # => true/false
1384
1912
  message.pending? # => true/false
1913
+ message.to_h # => Hash of the above
1385
1914
  ```
1386
1915
 
1916
+ There is no `updated_at` and no `error_message`; the failure text is on
1917
+ `error`. `Sendly::Message` is what `messages.send`, `messages.get` and
1918
+ `messages.list` return for SMS — a WhatsApp send returns a
1919
+ `Sendly::WhatsAppMessage`, an RCS send a `Sendly::RcsMessage`, and a group
1920
+ send a `Sendly::GroupMessage`, each with its own channel fields.
1921
+
1387
1922
  ## Message Status
1388
1923
 
1924
+ `Sendly::Message::STATUSES` is the vocabulary:
1925
+
1389
1926
  | Status | Description |
1390
1927
  |--------|-------------|
1391
1928
  | `queued` | Message is queued for delivery |
1392
- | `sending` | Message is being sent |
1393
1929
  | `sent` | Message was sent to carrier |
1394
1930
  | `delivered` | Message was delivered |
1395
1931
  | `failed` | Message delivery failed |
1932
+ | `bounced` | Invalid recipient, or the carrier rejected it |
1933
+ | `retrying` | A failed send is being retried |
1934
+
1935
+ There is no `sending` status. WhatsApp and RCS additionally report reads,
1936
+ as the `message.read` webhook (`Sendly::Webhooks::EVENT_MESSAGE_READ`);
1937
+ SMS has no read receipts.
1396
1938
 
1397
1939
  ## Pricing Tiers
1398
1940
 
1941
+ Per segment, at 1 credit = $0.01.
1942
+
1399
1943
  | Tier | Countries | Credits per SMS |
1400
1944
  |------|-----------|-----------------|
1401
1945
  | Domestic | US, CA | 2 |
1402
- | Tier 1 | GB, PL, IN, etc. | 8 |
1403
- | Tier 2 | FR, JP, AU, etc. | 12 |
1404
- | Tier 3 | DE, IT, MX, etc. | 16 |
1946
+ | Tier 1 | GB, AU, PL, PT, SE, DK, etc. | 8 |
1947
+ | Tier 2 | FR, JP, IN, IT, etc. | 12 |
1948
+ | Tier 3 | DE, MX, etc. | 16 |
1949
+ | Tier 4 | GE, MU, ME, AD, etc. | 24 |
1950
+ | Tier 5 | IL, SI, etc. | 48 |
1951
+
1952
+ A multi-segment message costs its tier's rate per segment. Enterprise
1953
+ accounts can have per-country or per-tier rates overridden, in which case
1954
+ the override applies instead.
1405
1955
 
1406
1956
  ## Sandbox Testing
1407
1957
 
@@ -1418,14 +1968,14 @@ Use test API keys (`sk_test_v1_xxx`) with these test numbers:
1418
1968
 
1419
1969
  ## Enterprise
1420
1970
 
1421
- 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_*`).
1971
+ 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.
1422
1972
 
1423
1973
  ### Quick Provision
1424
1974
 
1425
1975
  Create a fully configured workspace in a single call:
1426
1976
 
1427
1977
  ```ruby
1428
- client = Sendly::Client.new(api_key: "sk_live_v1_master_YOUR_KEY")
1978
+ client = Sendly::Client.new(api_key: "sk_live_v1_your_master_key")
1429
1979
 
1430
1980
  result = client.enterprise.provision(
1431
1981
  name: "Acme Insurance - Austin",
@@ -1464,9 +2014,12 @@ client.enterprise.workspaces.delete("ws_xxx")
1464
2014
  client.enterprise.workspaces.transfer_credits("ws_dest",
1465
2015
  source_workspace_id: "ws_source", amount: 5000)
1466
2016
 
2017
+ # type: is "test" (the default) or "live"; scopes: defaults to every scope.
2018
+ # A name is required: a missing or empty one raises ArgumentError.
1467
2019
  key = client.enterprise.workspaces.create_key("ws_xxx",
1468
- name: "Production", type: "live")
1469
- puts key["key"]
2020
+ name: "Production", type: "live", scopes: ["sms:send", "sms:read"])
2021
+ puts key["key"] # shown only once
2022
+ puts key["scopes"].inspect
1470
2023
 
1471
2024
  client.enterprise.workspaces.revoke_key("ws_xxx", "key_abc")
1472
2025
  ```
@@ -1474,12 +2027,72 @@ client.enterprise.workspaces.revoke_key("ws_xxx", "key_abc")
1474
2027
  ### Webhooks & Analytics
1475
2028
 
1476
2029
  ```ruby
1477
- client.enterprise.webhooks.set(url: "https://yourapp.com/webhooks")
2030
+ client.enterprise.webhooks.set(url: "https://hooks.acme.example/enterprise")
2031
+ client.enterprise.webhooks.test
2032
+ client.enterprise.webhooks.rotate_secret
2033
+
1478
2034
  overview = client.enterprise.analytics.overview
1479
2035
  messages = client.enterprise.analytics.messages(period: "30d")
1480
2036
  delivery = client.enterprise.analytics.delivery
2037
+ credits = client.enterprise.analytics.credits(period: "30d")
1481
2038
  ```
1482
2039
 
2040
+ ### The rest of the enterprise surface
2041
+
2042
+ ```ruby
2043
+ # Verification, per workspace. A first submission needs business_name, website,
2044
+ # address, contact, use_case, use_case_summary, sample_messages and
2045
+ # opt_in_workflow. A resubmit after a rejection may send only the top-level
2046
+ # fields that change, but an address or contact Hash replaces the stored one
2047
+ # whole, so send every key of it.
2048
+ client.enterprise.workspaces.submit_verification("ws_xxx", **verification_fields)
2049
+ client.enterprise.workspaces.resubmit_verification("ws_xxx",
2050
+ contact: { firstName: "Sam", lastName: "Rivera", email: "new@acme.example", phone: "+15555550100" })
2051
+ # Share the source workspace's verification and sending number (nothing is
2052
+ # bought), or copy only its business details and buy the workspace its own
2053
+ # toll-free number; the response then has "newNumber" => true.
2054
+ client.enterprise.workspaces.inherit_verification("ws_xxx", source_workspace_id: "ws_verified")
2055
+ client.enterprise.workspaces.inherit_verification("ws_other", source_workspace_id: "ws_verified",
2056
+ purchase_new_number: true)
2057
+ client.enterprise.workspaces.get_verification("ws_xxx")
2058
+ client.enterprise.upload_verification_document("./CP-575.pdf", workspace_id: "ws_xxx")
2059
+
2060
+ # Hosted opt-in pages, and a generated business page
2061
+ client.enterprise.workspaces.list_opt_in_pages("ws_xxx")
2062
+ client.enterprise.workspaces.create_opt_in_page("ws_xxx", business_name: "Acme LLC")
2063
+ client.enterprise.workspaces.update_opt_in_page("ws_xxx", "page_x", header_color: "#5B3A29")
2064
+ client.enterprise.workspaces.delete_opt_in_page("ws_xxx", "page_x")
2065
+ client.enterprise.workspaces.set_custom_domain("ws_xxx", "page_x", domain: "sms.acme.example")
2066
+ client.enterprise.generate_business_page(business_name: "Acme LLC")
2067
+
2068
+ # Members: invite into a workspace, list and cancel invitations
2069
+ client.enterprise.workspaces.send_invitation("ws_xxx", email: "sam@acme.example", role: "member")
2070
+ client.enterprise.workspaces.list_invitations("ws_xxx")
2071
+ client.enterprise.workspaces.cancel_invitation("ws_xxx", "inv_x")
2072
+
2073
+ # Suspend / resume, quotas, per-workspace webhooks, bulk provisioning
2074
+ client.enterprise.workspaces.suspend("ws_xxx", reason: "non-payment")
2075
+ client.enterprise.workspaces.resume("ws_xxx")
2076
+ client.enterprise.workspaces.get_quota("ws_xxx")
2077
+ client.enterprise.workspaces.set_quota("ws_xxx", monthly_message_quota: 50_000)
2078
+ client.enterprise.workspaces.set_webhook("ws_xxx", url: "https://yourapp.example/hooks")
2079
+ client.enterprise.workspaces.list_webhooks("ws_xxx")
2080
+ client.enterprise.workspaces.test_webhook("ws_xxx")
2081
+ client.enterprise.workspaces.delete_webhooks("ws_xxx", webhook_id: "whk_xxx")
2082
+ client.enterprise.workspaces.provision_bulk([{ name: "Acme Austin" }]) # max 100
2083
+ client.enterprise.workspaces.get_credits("ws_xxx")
2084
+ client.enterprise.workspaces.list_keys("ws_xxx")
2085
+
2086
+ # Enterprise-level account, credits and billing
2087
+ client.enterprise.get_account
2088
+ client.enterprise.credits.get
2089
+ client.enterprise.settings.get_auto_top_up
2090
+ client.enterprise.settings.update_auto_top_up(enabled: true, threshold: 1000, amount: 10_000)
2091
+ client.enterprise.billing.get_breakdown(period: "30d")
2092
+ ```
2093
+
2094
+ These return the API's parsed Hash rather than typed objects.
2095
+
1483
2096
  Full enterprise docs: [sendly.live/docs/enterprise](https://sendly.live/docs/enterprise)
1484
2097
 
1485
2098
  ---