sendly 3.37.1 → 3.39.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 +85 -1
- data/Gemfile.lock +2 -2
- data/README.md +305 -11
- data/lib/sendly/account_resource.rb +12 -8
- data/lib/sendly/business_upgrade_resource.rb +2 -0
- data/lib/sendly/client.rb +83 -8
- data/lib/sendly/enterprise.rb +2 -0
- data/lib/sendly/errors.rb +4 -2
- data/lib/sendly/messages.rb +160 -13
- data/lib/sendly/rcs_resource.rb +1155 -0
- data/lib/sendly/templates_resource.rb +169 -37
- data/lib/sendly/version.rb +1 -1
- data/lib/sendly/whatsapp_resource.rb +655 -0
- data/lib/sendly.rb +2 -0
- data/sendly.gemspec +51 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c474ade97511ceecadbff15268b602edb67953395417996e9c1ac8b7626e68ff
|
|
4
|
+
data.tar.gz: b034a8b316eab9f85e98de49e31db3045326662beeefbafb306844302a147037
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 10f0a1eecb587b5c0b801c1abb5366d41caaa4fecf7e274ac3a37860d82bbdf92ca644280a419626391d0fce53679c35172a66aca3f26893d780bb499321c15c
|
|
7
|
+
data.tar.gz: 0221a6cfd5de361ff4648b4b4e0256ceccf4bab50e3b44b2616cd2200c136c2acd27409c3b20a99f2f81176663e0e272664a07d07f113d29d2b2064a0546bd33
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,89 @@
|
|
|
1
1
|
# sendly (Ruby)
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- **`Sendly::ValidationError#field_errors` is now populated.** It was always `nil` before, because the API path never passed it. It now carries the response body's `errors` array on any 400 or 422, on every resource rather than just RCS: `client.contacts.import` already returns one, for example. Each entry is a Hash. Code that treats a truthy `field_errors` as "this only happens for X" should be rechecked.
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
- **RCS agent registration is self-serve from the SDK.** `client.rcs` gains `registration.get`, `dossier.get`, `brands.create` / `brands.update`, and `agents.create` / `get` / `update` / `set_test_devices` / `submit` / `request_launch`, mirroring the dashboard: draft the brand and agent, submit them for Sendly's review, invite test devices once the agent is in testing, then request launch. Reads need the `rcs:read` scope and writes `rcs:write`; test and live keys both work. Nested hashes (address, contact, basics, campaign, testing) accept snake_case or camelCase keys. Logo, hero and call-to-action media must be public `https://` URLs; assets cannot be uploaded over the API. New models: `Sendly::RcsRegistration` (with `CUSTOMER_STAGES`, `REVIEW_STATUSES` and `ERROR_CODES`), `Sendly::RcsDossier`, `Sendly::RcsBrand`, `Sendly::RcsAddress`, `Sendly::RcsContact`, `Sendly::RcsAgentRegistration`, `Sendly::RcsAgentBasics`, `Sendly::RcsAgentCampaign`, `Sendly::RcsCampaignInteraction`, `Sendly::RcsConsentSettings`, `Sendly::RcsOptInMethod`, `Sendly::RcsAgentTesting` and `Sendly::RcsTestDevice`. `Sendly::RcsAgent` (from `agents.list`) gains `stage`. Every route stays behind the RCS rollout: while it is off for your account these calls raise `Sendly::NotFoundError` with `rcs_not_enabled`.
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
dossier = client.rcs.dossier.get
|
|
14
|
+
brand = client.rcs.brands.create(**dossier.brand)
|
|
15
|
+
agent = client.rcs.agents.create(brand_id: brand.id, display_name: "Acme Coffee",
|
|
16
|
+
use_case: "MULTI_USE",
|
|
17
|
+
basics: { logo_url: "https://acme.example/rcs/logo.png" })
|
|
18
|
+
client.rcs.agents.submit(agent.id, idempotency_key: "rcs-submit-#{agent.id}")
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- **`client.patch` and `client.put` accept `idempotency_key:`.** Neither generates a key on its own (unchanged), but a key you pass is now sent, so the RCS `update` and `set_test_devices` calls can be replayed safely.
|
|
22
|
+
|
|
23
|
+
- **`Sendly::ValidationError#field_errors` carries the API's `errors` list** (`[{ "path", "message" }, ...]`) when a 400 or 422 response includes one, instead of always being `nil`. RCS registration uses it to say which brand, agent, campaign or device field needs attention.
|
|
24
|
+
|
|
25
|
+
## 3.38.0
|
|
26
|
+
|
|
27
|
+
### Minor Changes
|
|
28
|
+
|
|
29
|
+
- **Every `POST` now sends an `Idempotency-Key` header.** The client generates one key per logical request (`sendly-ruby-retry-<uuid>`) and holds it across its own retries, so a request that already reached the server before a rate-limit retry is recognised as a repeat instead of being executed a second time. The server records a key only once the first attempt has finished, so this narrows the duplicate-send window rather than closing it: a retry that fires while the original is still running is not seen as a repeat. No code change is needed to get this. To extend the same protection across process restarts or your own retry loop, supply the key yourself:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
client.messages.send(
|
|
33
|
+
to: "+15551234567",
|
|
34
|
+
text: "Your order shipped",
|
|
35
|
+
idempotency_key: "order-4821-shipped"
|
|
36
|
+
)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Repeating a request with the same key inside 24 hours returns the original response instead of sending again. `idempotency_key:` is accepted on `messages.send` (the SMS, WhatsApp and RCS branches alike), `messages.send_group`, `messages.schedule`, `messages.send_batch`, and on `client.post` for any call you assemble by hand. A key must be 1 to 255 printable ASCII characters. Surrounding whitespace is trimmed, an empty or whitespace-only key is treated as if you passed nothing, and anything else raises `Sendly::ValidationError` before a request leaves the process.
|
|
40
|
+
|
|
41
|
+
- **How keys behave across retries.** On a rate-limit retry the same key is reused. On a 5xx retry an auto-generated key is swapped for a fresh one, because the server responded, so the outcome is known and the retry should be a fresh attempt rather than a repeat of the failed one. The server does not record a 5xx against a key either. A key you supplied is never swapped, which is the whole point of supplying one. `messages.send_batch` is the deliberate exception: it sends no auto-generated key, because the batch endpoint already dedupes header-less retries by hashing the send itself and an auto key would step around that safety net. A key you pass to `send_batch` yourself is still sent. Worth knowing: this client raises `Sendly::TimeoutError` on a timeout rather than retrying, so a timeout is exactly the case where you should pass your own `idempotency_key:` before retrying by hand.
|
|
42
|
+
|
|
43
|
+
- **Multipart uploads carry a key as well.** `media.upload`, the enterprise verification-document upload, and `business_upgrade.start` / `business_upgrade.resubmit` now attach an auto-generated `Idempotency-Key` to their uploads, so a retried document upload is far less likely to land twice. These methods generate the key internally and do not take an `idempotency_key:` argument yet.
|
|
44
|
+
|
|
45
|
+
- **Templates were addressing a path the API does not serve. They now work.** Every method on `client.templates` other than `generate` pointed at `/verify/templates...`, which is not registered at any version of the API, so `list`, `get`, `create`, `update`, `delete` and `publish` could only ever raise `Sendly::NotFoundError`. They now address `/api/v1/templates`, which is served, and have been exercised end to end against production. If you wrote code against this resource and concluded it was broken, note carefully that it is live now: calls that previously failed without side effects will really create, edit, publish and delete templates.
|
|
46
|
+
|
|
47
|
+
- **`Sendly::Template` now mirrors what the API actually returns**, and templates have a draft/published lifecycle. The response body field is `text`, not `body`, and a template carries `status` (`"draft"` or `"published"`, see the new `Sendly::Template::STATUSES`), `version`, `published_at`, `is_preset` and `preset_slug`. New templates are always created as drafts; call `publish` to make one usable. Only drafts can be edited, so an `update` on a published template is rejected by the API, and preset templates cannot be edited at all.
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
t = client.templates.create(name: "Order shipped", text: "Hi {{name}}, order {{order_id}} has shipped!")
|
|
51
|
+
t.status # => "draft"
|
|
52
|
+
client.templates.publish(t.id)
|
|
53
|
+
client.templates.list[:templates].each { |x| puts "#{x.name}: #{x.status}" }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- **Template members that disappeared in the reshape are back, and deprecated.** If your editor or `ruby -w` starts pointing at these, this is why:
|
|
57
|
+
- `Template#body` is an alias of `#text`. Use `#text`.
|
|
58
|
+
- `Template#type` is derived from `#is_preset` and still returns `"preset"` or `"custom"`. Use `#is_preset` or `#preset?`. `Template::TYPES` is kept for the same reason.
|
|
59
|
+
- `Template#is_published` is derived from `#status`. Use `#status` or `#published?`.
|
|
60
|
+
- `Template#locale` is **always `nil`** and `Template#is_default` is **always `false`**. These are not deprecated in favour of anything: templates are not scoped by locale and the API has no concept of a default template, so it returns no such fields. There is no replacement. Keep per-locale wording in separate templates.
|
|
61
|
+
|
|
62
|
+
`Template#to_h` includes all of the above alongside the current fields, so hashes built from it keep their old keys.
|
|
63
|
+
|
|
64
|
+
- **Deprecated template keyword arguments now raise instead of lying.** `templates.list` accepts `limit:`, `type:` and `locale:` again, and `templates.create` / `templates.update` accept `body:`, `locale:` and `is_published:` again, but the ones the API cannot honour raise `ArgumentError` with an explanation rather than silently doing nothing:
|
|
65
|
+
- `list(limit:)` and `list(type:)` raise: the list route returns every visible template in one response and neither paginates nor filters. Slice the returned array, or select over it with `Template#preset?` / `#custom?`. `list` still returns a `:pagination` key so existing destructuring does not blow up, but it is always `nil`.
|
|
66
|
+
- `locale:` raises everywhere it is accepted.
|
|
67
|
+
- `create(is_published: true)` and `update(is_published: true)` raise, and point you at `publish(id)`. `is_published: false` is accepted as a no-op, since templates are created as drafts and an update never changes status.
|
|
68
|
+
- `body:` on `create` and `update` is accepted and sent as `text`. Prefer `text:`.
|
|
69
|
+
|
|
70
|
+
- **API key management was pointed at routes that do not exist.** `account.api_keys`, `account.api_key(id)` and `account.api_key_usage(id)` requested `/keys...`, which the versioned API does not serve, so they returned 404 no matter what. They now use `/account/keys...`. `account.api_keys` also unwraps the `keys` envelope the API returns, which it previously did not, so it now gives you the `Sendly::ApiKey` array its signature always promised.
|
|
71
|
+
|
|
72
|
+
- **`account.revoke_api_key` could never revoke anything.** It sent `DELETE /account/keys/:id`, and that path is registered for `GET` only, so every revocation failed. It now sends `PATCH /account/keys/:id/revoke`, which is the verb the server accepts, takes an optional `reason:` recorded on the key's audit trail, and returns the `{ "id", "name", "revoked", "revokedAt" }` hash from the API instead of nothing. Treat this as live: code that has been calling it fruitlessly will now actually revoke keys.
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
client.account.revoke_api_key("key_abc123", reason: "rotated")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- **`account.transactions` raised `TypeError` on every call.** The endpoint returns `{ "transactions": [...] }` and the SDK mapped over that hash directly, so it tried to index an array with a string and blew up before you saw any data. It now unwraps the envelope and returns `Sendly::CreditTransaction` objects, or an empty array for an account with no history. One caveat: `offset:` is still accepted by the method but the endpoint ignores it, so it has no effect. `limit:` works and the server caps it at 100 (50 when omitted).
|
|
79
|
+
|
|
80
|
+
- **Not fixed, so you are not left hunting:** `templates.unpublish` and `templates.clone` now address `/api/v1/templates/:id/unpublish` and `/api/v1/templates/:id/clone`, but the versioned API serves neither route, so both still fail with a 404. Their docs say so. To retire a published template today, create and publish a replacement and delete the old one; to copy one, read it with `get` and pass its `text` to `create`. Separately, `account.create_api_key` still fails with a 400: the API requires a `type` of `"test"` or `"live"` and the SDK does not send one. Mint keys from the dashboard until that is fixed.
|
|
81
|
+
|
|
82
|
+
### Patch Changes
|
|
83
|
+
|
|
84
|
+
- **`faraday` and `faraday-retry` are deprecated dependencies.** The client is built on Ruby's standard-library `net/http` and has not used Faraday at runtime for some time. Both gems stay declared in the gemspec so that this minor release does not pull a dependency out from under anyone resolving it transitively, but they are unused and are slated for removal in the next major version. The README no longer lists Faraday as a requirement.
|
|
85
|
+
- The gem's packaged file list is now an explicit manifest plus `lib/**/*.rb` and `examples/**/*.rb`, rather than a `git ls-files` shell-out. The contents are unchanged, but building the gem from a source tree that is not a git checkout now produces the same gem instead of an empty one.
|
|
86
|
+
|
|
3
87
|
## 3.33.0
|
|
4
88
|
|
|
5
89
|
### Minor Changes
|
|
@@ -87,7 +171,7 @@
|
|
|
87
171
|
|
|
88
172
|
- `/api/v1/enterprise/workspaces/:id/verification/submit` now returns specific missing-field errors (e.g. `"Missing required fields: website"`) instead of listing every required field whether present or not.
|
|
89
173
|
- Endpoint accepts both flat and `{ verification: {...} }` wrapped shapes (matches `/enterprise/provision`).
|
|
90
|
-
- `use_case` validation expanded from 23 entries to the full 43-value
|
|
174
|
+
- `use_case` validation expanded from 23 entries to the full 43-value carrier use-case enum.
|
|
91
175
|
|
|
92
176
|
## 3.29.0
|
|
93
177
|
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
sendly (3.
|
|
4
|
+
sendly (3.39.0)
|
|
5
5
|
faraday (~> 2.0)
|
|
6
6
|
faraday-retry (~> 2.0)
|
|
7
7
|
|
|
@@ -74,7 +74,7 @@ GEM
|
|
|
74
74
|
unicode-emoji (~> 4.1)
|
|
75
75
|
unicode-emoji (4.1.0)
|
|
76
76
|
uri (1.1.1)
|
|
77
|
-
webmock (3.26.
|
|
77
|
+
webmock (3.26.4)
|
|
78
78
|
addressable (>= 2.8.0)
|
|
79
79
|
crack (>= 0.3.2)
|
|
80
80
|
hashdiff (>= 0.4.0, < 2.0.0)
|
data/README.md
CHANGED
|
@@ -280,6 +280,32 @@ puts result.explanation # what changed and why
|
|
|
280
280
|
puts result.model # model used (when available)
|
|
281
281
|
```
|
|
282
282
|
|
|
283
|
+
## Idempotency
|
|
284
|
+
|
|
285
|
+
Every POST carries an automatically generated `Idempotency-Key` header, held
|
|
286
|
+
across the client's own rate-limit retries, so a retry of a request that
|
|
287
|
+
already reached the API returns the original result instead of sending and
|
|
288
|
+
charging again. Pass your own key (1-255 printable ASCII characters) when the
|
|
289
|
+
guarantee needs to outlive the process, such as a job queue that re-runs after
|
|
290
|
+
a crash or your own retry loop; `idempotency_key:` is accepted on
|
|
291
|
+
`messages.send`, `send_group`, `schedule`, and `send_batch`.
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
message = client.messages.send(
|
|
295
|
+
to: "+15551234567",
|
|
296
|
+
text: "Your order has shipped!",
|
|
297
|
+
idempotency_key: "order-4821-shipped"
|
|
298
|
+
)
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Repeating a request with the same key within 24 hours returns the original
|
|
302
|
+
response; `send_batch` sends no automatic key, because the API already
|
|
303
|
+
deduplicates identical batches by their contents. Note this client raises
|
|
304
|
+
`Sendly::TimeoutError` instead of retrying a timeout, so a timeout is exactly
|
|
305
|
+
when to retry with your own key.
|
|
306
|
+
|
|
307
|
+
Full details: https://sendly.live/docs/idempotency
|
|
308
|
+
|
|
283
309
|
## Webhooks
|
|
284
310
|
|
|
285
311
|
```ruby
|
|
@@ -330,13 +356,13 @@ puts "Total: #{credits['balance']} credits"
|
|
|
330
356
|
# View credit transaction history
|
|
331
357
|
transactions = client.account.transactions
|
|
332
358
|
transactions.each do |tx|
|
|
333
|
-
puts "#{tx
|
|
359
|
+
puts "#{tx.type}: #{tx.amount} credits - #{tx.description}"
|
|
334
360
|
end
|
|
335
361
|
|
|
336
362
|
# List API keys
|
|
337
363
|
keys = client.account.api_keys
|
|
338
364
|
keys.each do |key|
|
|
339
|
-
puts "#{key
|
|
365
|
+
puts "#{key.name}: #{key.prefix} (#{key.type})"
|
|
340
366
|
end
|
|
341
367
|
|
|
342
368
|
# Create a new API key
|
|
@@ -457,20 +483,17 @@ client.campaigns.delete(campaign.id)
|
|
|
457
483
|
Reusable message templates with variables. AI can also draft one for you.
|
|
458
484
|
|
|
459
485
|
```ruby
|
|
460
|
-
# Create / list / get
|
|
486
|
+
# Create / list / get. New templates start as drafts; publish to lock one for use.
|
|
461
487
|
template = client.templates.create(
|
|
462
488
|
name: "Order shipped",
|
|
463
|
-
|
|
464
|
-
is_published: true
|
|
489
|
+
text: "Hi {{name}}, order {{order_id}} has shipped!"
|
|
465
490
|
)
|
|
466
|
-
client.templates.list
|
|
491
|
+
client.templates.list[:templates].each { |t| puts "#{t.name} — #{t.status}" }
|
|
467
492
|
t = client.templates.get(template.id)
|
|
468
493
|
|
|
469
|
-
# Update, publish
|
|
470
|
-
client.templates.update(template.id,
|
|
494
|
+
# Update (drafts only), publish, delete
|
|
495
|
+
client.templates.update(template.id, text: "Hi {{name}}, your order is on the way!")
|
|
471
496
|
client.templates.publish(template.id)
|
|
472
|
-
client.templates.unpublish(template.id)
|
|
473
|
-
client.templates.clone(template.id, name: "Order shipped (copy)")
|
|
474
497
|
client.templates.delete(template.id)
|
|
475
498
|
|
|
476
499
|
# Generate a template with AI
|
|
@@ -745,6 +768,276 @@ status = client.links.update(link.code, disabled: true)
|
|
|
745
768
|
puts status.disabled?
|
|
746
769
|
```
|
|
747
770
|
|
|
771
|
+
## WhatsApp
|
|
772
|
+
|
|
773
|
+
Connect a number you own to WhatsApp, create Meta-reviewed message
|
|
774
|
+
templates, and send via `client.messages.send(channel: "whatsapp", ...)`.
|
|
775
|
+
Connecting is a one-time $19 setup (no monthly fee) and always ends with a
|
|
776
|
+
human step: the signup returns a connect URL a person must open in a
|
|
777
|
+
browser and log in with Facebook to link their WhatsApp Business Account.
|
|
778
|
+
|
|
779
|
+
Free-form text and media only deliver inside a 24-hour customer-service
|
|
780
|
+
window (opened by the recipient messaging you); an approved template works
|
|
781
|
+
anytime. Templates are reviewed by Meta (typically 24-48h) and categorized
|
|
782
|
+
as authentication, utility, or marketing — pricing follows the category and
|
|
783
|
+
destination country. Note: Meta has paused marketing template delivery to
|
|
784
|
+
US (+1) numbers.
|
|
785
|
+
|
|
786
|
+
```ruby
|
|
787
|
+
# 1. Connect a number ($19 one-time; a human must open the connect URL)
|
|
788
|
+
signup = client.whatsapp.signup.create(phone_number: "+15559876543")
|
|
789
|
+
puts "Have your user open: #{signup.connect_url}"
|
|
790
|
+
|
|
791
|
+
# 2. Poll until active
|
|
792
|
+
status = client.whatsapp.signup.get(signup.id)
|
|
793
|
+
puts status.failure_reasons if status.failed?
|
|
794
|
+
|
|
795
|
+
# List your connected senders
|
|
796
|
+
client.whatsapp.senders.list[:senders].each do |s|
|
|
797
|
+
puts "#{s.phone_number} (#{s.display_name || 'no name yet'}) — #{s.status}"
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
# Read and update a sender's business profile (what recipients see when
|
|
801
|
+
# they open your details in WhatsApp)
|
|
802
|
+
profile = client.whatsapp.senders.get_profile("+15559876543")
|
|
803
|
+
puts profile.display_name
|
|
804
|
+
puts profile.about
|
|
805
|
+
|
|
806
|
+
client.whatsapp.senders.update_profile(
|
|
807
|
+
"+15559876543",
|
|
808
|
+
about: "Fresh roasted coffee, delivered.", # max 139 chars
|
|
809
|
+
description: "Small-batch roaster shipping nationwide.", # max 512 chars
|
|
810
|
+
website: "https://acme.example"
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
# 3. Create a template (Meta reviews it, usually 24-48h)
|
|
814
|
+
template = client.whatsapp.templates.create(
|
|
815
|
+
sender: "+15559876543",
|
|
816
|
+
name: "order_shipped",
|
|
817
|
+
language: "en_US",
|
|
818
|
+
category: "UTILITY",
|
|
819
|
+
body: "Hi {{1}}, your order {{2}} has shipped!",
|
|
820
|
+
examples: { "1" => "Sam", "2" => "#4821" }
|
|
821
|
+
)
|
|
822
|
+
puts template.status # "PENDING"
|
|
823
|
+
|
|
824
|
+
# List, edit-and-resubmit (the recovery path for rejections), or delete
|
|
825
|
+
client.whatsapp.templates.list[:templates].each { |t| puts "#{t.name} — #{t.status}" }
|
|
826
|
+
client.whatsapp.templates.update(template.id, body: "Hi {{1}}, order {{2}} is on its way!",
|
|
827
|
+
examples: { "1" => "Sam", "2" => "#4821" })
|
|
828
|
+
client.whatsapp.templates.delete(template.id)
|
|
829
|
+
|
|
830
|
+
# 4. Send — free-form inside an open 24h window, template anytime
|
|
831
|
+
window = client.whatsapp.window(from: "+15559876543", to: "+15551234567")
|
|
832
|
+
if window.open?
|
|
833
|
+
client.messages.send(
|
|
834
|
+
channel: "whatsapp",
|
|
835
|
+
to: "+15551234567",
|
|
836
|
+
from: "+15559876543",
|
|
837
|
+
text: "Your table is ready!"
|
|
838
|
+
)
|
|
839
|
+
else
|
|
840
|
+
message = client.messages.send(
|
|
841
|
+
channel: "whatsapp",
|
|
842
|
+
to: "+15551234567",
|
|
843
|
+
from: "+15559876543",
|
|
844
|
+
template: {
|
|
845
|
+
name: "order_shipped",
|
|
846
|
+
language: "en_US",
|
|
847
|
+
variables: { "1" => "Acme Inc", "2" => "#4821" }
|
|
848
|
+
}
|
|
849
|
+
)
|
|
850
|
+
puts message.whatsapp.kind # "template"
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
# Media with a caption (also window-bound; one attachment per message)
|
|
854
|
+
client.messages.send(
|
|
855
|
+
channel: "whatsapp",
|
|
856
|
+
to: "+15551234567",
|
|
857
|
+
from: "+15559876543",
|
|
858
|
+
text: "Here is your receipt",
|
|
859
|
+
media_urls: ["https://example.com/receipt.pdf"]
|
|
860
|
+
)
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
## RCS
|
|
864
|
+
|
|
865
|
+
Send branded rich messages — text with suggested replies and actions, or
|
|
866
|
+
rich cards with an image and buttons — through your workspace's RCS agent
|
|
867
|
+
by passing `channel: "rcs"` to `client.messages.send`. Delivery is
|
|
868
|
+
per-recipient: not every device or network supports RCS. Text sends fall
|
|
869
|
+
back to plain SMS automatically (billed as SMS) unless you disable the
|
|
870
|
+
fallback; rich cards have no SMS form and only deliver to RCS-capable
|
|
871
|
+
recipients.
|
|
872
|
+
|
|
873
|
+
The RCS channel is being rolled out gradually and is not yet generally
|
|
874
|
+
available; until it is enabled for your account the endpoints read as
|
|
875
|
+
absent and calls raise `Sendly::NotFoundError` (HTTP 404: `not_found` on
|
|
876
|
+
sends and listing, `rcs_not_enabled` on registration). RCS sends and
|
|
877
|
+
capability checks require a live API key.
|
|
878
|
+
|
|
879
|
+
### Registering an agent
|
|
880
|
+
|
|
881
|
+
Registration is self-serve, from the dashboard or the API. Draft the
|
|
882
|
+
brand (the business) and the agent (what recipients see), submit them for
|
|
883
|
+
Sendly's review, and once approved they go on to the carrier network for
|
|
884
|
+
brand verification and agent review. The agent then reaches invited test
|
|
885
|
+
devices only; when you have tested it, request launch and Sendly reviews
|
|
886
|
+
the campaign before sending the launch on to the carrier network. Watch
|
|
887
|
+
`customer_stage` (one of `Sendly::RcsRegistration::CUSTOMER_STAGES`) to
|
|
888
|
+
see where a registration is.
|
|
889
|
+
|
|
890
|
+
Registration reads need the `rcs:read` scope and writes `rcs:write`; test
|
|
891
|
+
and live keys both work, since drafting is not carrier-backed. Logo, hero
|
|
892
|
+
and call-to-action media must be public `https://` URLs: assets cannot be
|
|
893
|
+
uploaded over the API, only from the dashboard. Nested hashes (address,
|
|
894
|
+
contact, basics, campaign, testing) accept snake_case or camelCase keys.
|
|
895
|
+
Every write accepts `idempotency_key:`; `POST`s get an auto-generated key
|
|
896
|
+
as usual, while `PATCH` and `PUT` send one only when you pass it.
|
|
897
|
+
|
|
898
|
+
```ruby
|
|
899
|
+
# Where is the registration at?
|
|
900
|
+
reg = client.rcs.registration.get
|
|
901
|
+
puts reg.stage # "draft" until something is submitted
|
|
902
|
+
|
|
903
|
+
# Seed the brand from details Sendly already holds (your 10DLC brand or
|
|
904
|
+
# toll-free verification), then fill in the rest
|
|
905
|
+
dossier = client.rcs.dossier.get
|
|
906
|
+
brand = client.rcs.brands.create(**dossier.brand) # dossier.source: "tendlc", "verification" or "none"
|
|
907
|
+
client.rcs.brands.update(brand.id,
|
|
908
|
+
display_name: "Acme Coffee",
|
|
909
|
+
legal_entity_type: "LIMITED_LIABILITY_COMPANY",
|
|
910
|
+
contact: { first_name: "Sam", last_name: "Lee", email: "sam@acme.example",
|
|
911
|
+
phone_number: "+15551234567" }
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
# Draft the agent. Media must be public https URLs.
|
|
915
|
+
agent = client.rcs.agents.create(
|
|
916
|
+
brand_id: brand.id,
|
|
917
|
+
display_name: "Acme Coffee",
|
|
918
|
+
use_case: "MULTI_USE",
|
|
919
|
+
basics: {
|
|
920
|
+
description: "Order updates and offers from Acme Coffee",
|
|
921
|
+
logo_url: "https://acme.example/rcs/logo.png",
|
|
922
|
+
hero_url: "https://acme.example/rcs/hero.png",
|
|
923
|
+
brand_color: "#5B3A29",
|
|
924
|
+
privacy_policy_url: "https://acme.example/privacy",
|
|
925
|
+
terms_and_conditions_url: "https://acme.example/terms",
|
|
926
|
+
phone_number: { number: "+15551234567", label: "Support" }
|
|
927
|
+
}
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
# Submit for Sendly's review. Pass your own key if you may retry: a replay
|
|
931
|
+
# returns the original response without submitting again.
|
|
932
|
+
agent = client.rcs.agents.submit(agent.id, idempotency_key: "rcs-submit-#{agent.id}")
|
|
933
|
+
puts agent.review_status # "awaiting_review"
|
|
934
|
+
|
|
935
|
+
# Later: poll, and act on a review note
|
|
936
|
+
agent = client.rcs.agents.get(agent.id)
|
|
937
|
+
if agent.changes_requested?
|
|
938
|
+
puts agent.review_note
|
|
939
|
+
client.rcs.agents.update(agent.id, basics: { hero_url: "https://acme.example/rcs/hero-v2.png" })
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
# Once the agent is in testing, invite devices, describe the campaign,
|
|
943
|
+
# then request launch
|
|
944
|
+
client.rcs.agents.set_test_devices(agent.id, devices: [
|
|
945
|
+
{ phone_number: "+15557654321", label: "Sam's Pixel" }
|
|
946
|
+
])
|
|
947
|
+
client.rcs.agents.update(agent.id,
|
|
948
|
+
campaign: {
|
|
949
|
+
company_overview: "Specialty coffee roaster with three cafes in Austin",
|
|
950
|
+
agent_overview: "Order updates, pickup alerts and support replies",
|
|
951
|
+
interactions: [{ interaction_type: "TRANSACTIONAL_UPDATES",
|
|
952
|
+
description: "Order and pickup status" }],
|
|
953
|
+
message_examples: ["Your order #4821 has shipped!",
|
|
954
|
+
"Your latte is ready for pickup at 5th St",
|
|
955
|
+
"Reply HELP for support or STOP to opt out"],
|
|
956
|
+
consent_settings: {
|
|
957
|
+
opt_in_methods: [{ method_type: "WEBSITE", description: "Checkbox at checkout" }],
|
|
958
|
+
call_to_action: "Get order updates by RCS",
|
|
959
|
+
call_to_action_url: "https://acme.example/updates",
|
|
960
|
+
double_opt_in: false,
|
|
961
|
+
help_response: "Acme Coffee: reply STOP to opt out, or email help@acme.example",
|
|
962
|
+
opt_out_response: "You're opted out of Acme Coffee updates."
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
)
|
|
966
|
+
client.rcs.agents.request_launch(agent.id, test_url: "https://acme.example/rcs-test")
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
Registration errors map onto the usual classes: `Sendly::NotFoundError`
|
|
970
|
+
for `rcs_not_enabled` and `rcs_not_found`; `Sendly::ValidationError` for
|
|
971
|
+
`rcs_us_only` and `rcs_invalid_content`, whose `field_errors` is the API's
|
|
972
|
+
list of `{ "path", "message" }` hashes; and `Sendly::APIError` with
|
|
973
|
+
`status_code` 409 for `rcs_field_locked`, `rcs_brand_not_verified` and
|
|
974
|
+
`rcs_launch_not_ready`, or 403 for a key missing the scope.
|
|
975
|
+
|
|
976
|
+
### Sending
|
|
977
|
+
|
|
978
|
+
```ruby
|
|
979
|
+
# Discover your RCS agents ("testing" reaches invited test devices only;
|
|
980
|
+
# "approved" reaches everyone). Pass agent_id on sends and capability
|
|
981
|
+
# checks when your workspace has more than one agent.
|
|
982
|
+
client.rcs.agents.list[:agents].each do |a|
|
|
983
|
+
puts "#{a.name} — #{a.status}#{a.sendable? ? ' (sendable)' : ''}"
|
|
984
|
+
end
|
|
985
|
+
|
|
986
|
+
# Pre-flight: can this recipient receive RCS?
|
|
987
|
+
capability = client.rcs.capability(to: "+15551234567")
|
|
988
|
+
puts capability.capable? ? "RCS" : "would fall back to SMS"
|
|
989
|
+
|
|
990
|
+
# Text with suggested replies and actions. Nested suggestion and card
|
|
991
|
+
# hashes are passed through verbatim, so they use the camelCase keys the
|
|
992
|
+
# API expects.
|
|
993
|
+
message = client.messages.send(
|
|
994
|
+
channel: "rcs",
|
|
995
|
+
to: "+15551234567",
|
|
996
|
+
text: "Your order has shipped! Want live updates?",
|
|
997
|
+
suggestions: [
|
|
998
|
+
{ reply: { text: "Yes, notify me", postbackData: "notify_yes" } },
|
|
999
|
+
{ action: { text: "Track order", postbackData: "track",
|
|
1000
|
+
url: "https://acme.example/orders/4821" } }
|
|
1001
|
+
]
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
# The response discloses which channel delivered
|
|
1005
|
+
puts message.channel # "rcs", or "sms" when it fell back
|
|
1006
|
+
if message.fell_back?
|
|
1007
|
+
# Delivered as plain SMS (billed as SMS). Suggestions have no SMS form
|
|
1008
|
+
# and were dropped — message.rcs.suggestions_dropped is true.
|
|
1009
|
+
else
|
|
1010
|
+
puts message.rcs.kind # "text" or "card"
|
|
1011
|
+
puts message.rcs.agent_name # the brand name recipients see
|
|
1012
|
+
end
|
|
1013
|
+
|
|
1014
|
+
# Rich card (RCS-capable recipients only — cards have no SMS form)
|
|
1015
|
+
client.messages.send(
|
|
1016
|
+
channel: "rcs",
|
|
1017
|
+
to: "+15551234567",
|
|
1018
|
+
card: {
|
|
1019
|
+
title: "Spring collection",
|
|
1020
|
+
description: "New arrivals are in - take a look.",
|
|
1021
|
+
mediaUrl: "https://example.com/spring.jpg", # public JPEG, PNG, or GIF
|
|
1022
|
+
orientation: "vertical", # or "horizontal"
|
|
1023
|
+
suggestions: [
|
|
1024
|
+
{ action: { text: "Shop now", postbackData: "shop",
|
|
1025
|
+
url: "https://acme.example/spring" } }
|
|
1026
|
+
]
|
|
1027
|
+
}
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
# Opt out of the SMS fallback — the send raises Sendly::ValidationError
|
|
1031
|
+
# (HTTP 422 rcs_not_supported_for_recipient) when the recipient can't
|
|
1032
|
+
# receive RCS
|
|
1033
|
+
client.messages.send(
|
|
1034
|
+
channel: "rcs",
|
|
1035
|
+
to: "+15551234567",
|
|
1036
|
+
text: "RCS or nothing",
|
|
1037
|
+
fallback_to_sms: false
|
|
1038
|
+
)
|
|
1039
|
+
```
|
|
1040
|
+
|
|
748
1041
|
## Error Handling
|
|
749
1042
|
|
|
750
1043
|
```ruby
|
|
@@ -893,7 +1186,8 @@ Full enterprise docs: [sendly.live/docs/enterprise](https://sendly.live/docs/ent
|
|
|
893
1186
|
## Requirements
|
|
894
1187
|
|
|
895
1188
|
- Ruby 3.0+
|
|
896
|
-
|
|
1189
|
+
|
|
1190
|
+
The client is built on Ruby's standard-library `net/http` and does not use Faraday at runtime. The gemspec still declares `faraday` and `faraday-retry` so this release does not drop a runtime dependency that callers may be resolving transitively. Both are unused and are slated for removal in the next major version.
|
|
897
1191
|
|
|
898
1192
|
## License
|
|
899
1193
|
|
|
@@ -39,15 +39,15 @@ module Sendly
|
|
|
39
39
|
params[:offset] = offset if offset
|
|
40
40
|
|
|
41
41
|
response = @client.get("/credits/transactions", params)
|
|
42
|
-
response.map { |data| CreditTransaction.new(data) }
|
|
42
|
+
(response["transactions"] || []).map { |data| CreditTransaction.new(data) }
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
# List API keys for the account
|
|
46
46
|
#
|
|
47
47
|
# @return [Array<Sendly::ApiKey>]
|
|
48
48
|
def api_keys
|
|
49
|
-
response = @client.get("/keys")
|
|
50
|
-
response.map { |data| ApiKey.new(data) }
|
|
49
|
+
response = @client.get("/account/keys")
|
|
50
|
+
(response["keys"] || []).map { |data| ApiKey.new(data) }
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
# Get a specific API key by ID
|
|
@@ -55,7 +55,7 @@ module Sendly
|
|
|
55
55
|
# @param key_id [String] API key ID
|
|
56
56
|
# @return [Sendly::ApiKey]
|
|
57
57
|
def api_key(key_id)
|
|
58
|
-
response = @client.get("/keys/#{key_id}")
|
|
58
|
+
response = @client.get("/account/keys/#{key_id}")
|
|
59
59
|
ApiKey.new(response)
|
|
60
60
|
end
|
|
61
61
|
|
|
@@ -64,7 +64,7 @@ module Sendly
|
|
|
64
64
|
# @param key_id [String] API key ID
|
|
65
65
|
# @return [Hash] Usage statistics
|
|
66
66
|
def api_key_usage(key_id)
|
|
67
|
-
@client.get("/keys/#{key_id}/usage")
|
|
67
|
+
@client.get("/account/keys/#{key_id}/usage")
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
# Create a new API key
|
|
@@ -88,11 +88,15 @@ module Sendly
|
|
|
88
88
|
# Revoke an API key
|
|
89
89
|
#
|
|
90
90
|
# @param key_id [String] API key ID to revoke
|
|
91
|
-
# @
|
|
92
|
-
|
|
91
|
+
# @param reason [String, nil] Optional reason recorded on the key's audit trail
|
|
92
|
+
# @return [Hash] +{ "id" => ..., "name" => ..., "revoked" => true, "revokedAt" => ... }+
|
|
93
|
+
def revoke_api_key(key_id, reason: nil)
|
|
93
94
|
raise ArgumentError, "API key ID is required" if key_id.nil? || key_id.empty?
|
|
94
95
|
|
|
95
|
-
|
|
96
|
+
body = {}
|
|
97
|
+
body[:reason] = reason if reason
|
|
98
|
+
|
|
99
|
+
@client.patch("/account/keys/#{key_id}/revoke", body)
|
|
96
100
|
end
|
|
97
101
|
|
|
98
102
|
# Rotate an API key.
|
|
@@ -383,6 +383,8 @@ module Sendly
|
|
|
383
383
|
req["User-Agent"] = "sendly-ruby/#{Sendly::VERSION}"
|
|
384
384
|
req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
|
|
385
385
|
req["X-Organization-Id"] = @client.organization_id if @client.organization_id
|
|
386
|
+
# Single-use auto key (this path has no retry loop).
|
|
387
|
+
req["Idempotency-Key"] = @client.generate_idempotency_key
|
|
386
388
|
req.body = body_parts.join
|
|
387
389
|
|
|
388
390
|
begin
|