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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +90 -0
- data/Gemfile.lock +5 -5
- data/README.md +760 -147
- data/examples/list_messages.rb +1 -1
- data/examples/send_sms.rb +6 -2
- data/lib/sendly/account_resource.rb +16 -4
- data/lib/sendly/business_upgrade_resource.rb +3 -2
- data/lib/sendly/calls_resource.rb +20 -8
- data/lib/sendly/campaigns_resource.rb +59 -8
- data/lib/sendly/client.rb +38 -19
- data/lib/sendly/conversations_resource.rb +17 -5
- data/lib/sendly/drafts_resource.rb +2 -1
- data/lib/sendly/enterprise.rb +21 -10
- data/lib/sendly/errors.rb +46 -22
- data/lib/sendly/messages.rb +53 -13
- data/lib/sendly/types.rb +177 -26
- data/lib/sendly/version.rb +1 -1
- data/lib/sendly/webhooks.rb +5 -0
- data/lib/sendly/webhooks_resource.rb +30 -7
- data/lib/sendly/whatsapp_resource.rb +651 -46
- metadata +6 -6
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: "+
|
|
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
|
|
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: "+
|
|
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: "+
|
|
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: "+
|
|
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: "+
|
|
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: "+
|
|
130
|
+
to: "+15125550123",
|
|
122
131
|
text: "Hello from our team!",
|
|
123
|
-
from: "+
|
|
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: "+
|
|
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: "+
|
|
178
|
+
to: "+15125550123",
|
|
168
179
|
text: "Your appointment is tomorrow!",
|
|
169
|
-
scheduled_at:
|
|
180
|
+
scheduled_at: (Time.now.utc + 3600).iso8601 # 5 minutes to 5 days ahead
|
|
170
181
|
)
|
|
171
182
|
|
|
172
|
-
|
|
173
|
-
puts scheduled
|
|
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("
|
|
192
|
+
msg = client.messages.get_scheduled("schd_xxx")
|
|
181
193
|
|
|
182
194
|
# Cancel a scheduled message (refunds credits)
|
|
183
|
-
result = client.messages.cancel_scheduled("
|
|
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
|
|
202
|
+
# Send multiple messages in one API call (up to 10,000)
|
|
191
203
|
batch = client.messages.send_batch(
|
|
192
204
|
messages: [
|
|
193
|
-
{ to: "+
|
|
194
|
-
{ to: "+
|
|
195
|
-
{ to: "+
|
|
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 "
|
|
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: '+
|
|
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 "
|
|
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: ["+
|
|
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: ["+
|
|
257
|
-
media_urls: ["https://cdn.example
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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: "+
|
|
324
|
+
to: "+15125550123",
|
|
296
325
|
text: "Your order has shipped!",
|
|
297
326
|
idempotency_key: "order-4821-shipped"
|
|
298
327
|
)
|
|
299
328
|
```
|
|
300
329
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
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
|
|
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.*`, `
|
|
405
|
-
`assignment.*`, `number.*`, `port*`, `contact*`,
|
|
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
|
|
419
|
-
`
|
|
420
|
-
`
|
|
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
|
|
486
|
-
puts "Reserved: #{credits
|
|
487
|
-
puts "Total: #{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
|
-
|
|
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: "+
|
|
643
|
+
phone_number: "+15125550123",
|
|
524
644
|
name: "Alice Example",
|
|
525
|
-
email: "alice@example
|
|
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: "+
|
|
547
|
-
{ phone: "+
|
|
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
|
-
|
|
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
|
|
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
|
|
603
|
-
|
|
604
|
-
|
|
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: "
|
|
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)
|
|
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: {
|
|
711
|
-
actions: {
|
|
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: "+
|
|
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
|
|
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
|
|
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: "+
|
|
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
|
-
#
|
|
796
|
-
#
|
|
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.
|
|
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: '+
|
|
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
|
|
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
|
|
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)
|
|
910
|
-
human step: the signup returns a connect URL a person must open in
|
|
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
|
|
917
|
-
|
|
918
|
-
|
|
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: "+
|
|
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("+
|
|
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
|
-
"+
|
|
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: "+
|
|
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
|
-
|
|
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: "+
|
|
970
|
-
from: "+
|
|
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: "+
|
|
977
|
-
from: "+
|
|
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: "+
|
|
991
|
-
from: "+
|
|
1314
|
+
to: "+15555550100",
|
|
1315
|
+
from: "+15125550123",
|
|
992
1316
|
text: "Here is your receipt",
|
|
993
|
-
media_urls: ["https://example
|
|
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: "+
|
|
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: "+
|
|
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: "+
|
|
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: "+
|
|
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: "+
|
|
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: "+
|
|
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
|
|
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: "+
|
|
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: "+
|
|
1637
|
+
to: "+15125550123",
|
|
1206
1638
|
agent_id: "3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b",
|
|
1207
|
-
from: "+
|
|
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
|
|
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 (`
|
|
1258
|
-
`metadata`, ...); `
|
|
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("+
|
|
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
|
-
"+
|
|
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("+
|
|
1307
|
-
client.voice.numbers.update("+
|
|
1308
|
-
client.voice.numbers.update("+
|
|
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: "+
|
|
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
|
-
|
|
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 #
|
|
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.
|
|
1376
|
-
message.
|
|
1377
|
-
message.
|
|
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.
|
|
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,
|
|
1403
|
-
| Tier 2 | FR, JP,
|
|
1404
|
-
| Tier 3 | DE,
|
|
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.
|
|
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: "
|
|
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://
|
|
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
|
---
|