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