sendly 3.37.0 → 3.38.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 +63 -1
- data/Gemfile.lock +2 -2
- data/README.md +180 -11
- data/lib/sendly/account_resource.rb +12 -8
- data/lib/sendly/business_upgrade_resource.rb +2 -0
- data/lib/sendly/client.rb +71 -4
- data/lib/sendly/enterprise.rb +2 -0
- data/lib/sendly/messages.rb +160 -13
- data/lib/sendly/rcs_resource.rb +222 -0
- data/lib/sendly/templates_resource.rb +169 -37
- data/lib/sendly/version.rb +1 -1
- data/lib/sendly/webhooks.rb +16 -0
- 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: 76b3722f8971a04363ce5a5ea47d61eba59db05698652f21cd5ab347c24c74c1
|
|
4
|
+
data.tar.gz: 478881a38dac65c6f9696d0de53114fadc10651152304542519616794ed64bfe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: db802e6f2ac77bd0978de8a043b8a34488168e820e8b2e6ad3aca7033f43377b1524ccdee742077471486013d6f6e8607465b1fbaf68038dfb8d37d3f119915c
|
|
7
|
+
data.tar.gz: 33103fdeb0adf5816c3ffcdc23c4107eb52cc814ed904ae2b9d64f314d8fb88bbf2f0e837c0d4bd66781903710c7ffab770da166e5372eebe79d8522c3ace7d8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
1
1
|
# sendly (Ruby)
|
|
2
2
|
|
|
3
|
+
## 3.38.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- **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:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
client.messages.send(
|
|
11
|
+
to: "+15551234567",
|
|
12
|
+
text: "Your order shipped",
|
|
13
|
+
idempotency_key: "order-4821-shipped"
|
|
14
|
+
)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
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.
|
|
18
|
+
|
|
19
|
+
- **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.
|
|
20
|
+
|
|
21
|
+
- **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.
|
|
22
|
+
|
|
23
|
+
- **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.
|
|
24
|
+
|
|
25
|
+
- **`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.
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
t = client.templates.create(name: "Order shipped", text: "Hi {{name}}, order {{order_id}} has shipped!")
|
|
29
|
+
t.status # => "draft"
|
|
30
|
+
client.templates.publish(t.id)
|
|
31
|
+
client.templates.list[:templates].each { |x| puts "#{x.name}: #{x.status}" }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- **Template members that disappeared in the reshape are back, and deprecated.** If your editor or `ruby -w` starts pointing at these, this is why:
|
|
35
|
+
- `Template#body` is an alias of `#text`. Use `#text`.
|
|
36
|
+
- `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.
|
|
37
|
+
- `Template#is_published` is derived from `#status`. Use `#status` or `#published?`.
|
|
38
|
+
- `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.
|
|
39
|
+
|
|
40
|
+
`Template#to_h` includes all of the above alongside the current fields, so hashes built from it keep their old keys.
|
|
41
|
+
|
|
42
|
+
- **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:
|
|
43
|
+
- `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`.
|
|
44
|
+
- `locale:` raises everywhere it is accepted.
|
|
45
|
+
- `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.
|
|
46
|
+
- `body:` on `create` and `update` is accepted and sent as `text`. Prefer `text:`.
|
|
47
|
+
|
|
48
|
+
- **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.
|
|
49
|
+
|
|
50
|
+
- **`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.
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
client.account.revoke_api_key("key_abc123", reason: "rotated")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- **`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).
|
|
57
|
+
|
|
58
|
+
- **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.
|
|
59
|
+
|
|
60
|
+
### Patch Changes
|
|
61
|
+
|
|
62
|
+
- **`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.
|
|
63
|
+
- 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.
|
|
64
|
+
|
|
3
65
|
## 3.33.0
|
|
4
66
|
|
|
5
67
|
### Minor Changes
|
|
@@ -87,7 +149,7 @@
|
|
|
87
149
|
|
|
88
150
|
- `/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
151
|
- Endpoint accepts both flat and `{ verification: {...} }` wrapped shapes (matches `/enterprise/provision`).
|
|
90
|
-
- `use_case` validation expanded from 23 entries to the full 43-value
|
|
152
|
+
- `use_case` validation expanded from 23 entries to the full 43-value carrier use-case enum.
|
|
91
153
|
|
|
92
154
|
## 3.29.0
|
|
93
155
|
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
sendly (3.
|
|
4
|
+
sendly (3.38.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
|
@@ -330,13 +330,13 @@ puts "Total: #{credits['balance']} credits"
|
|
|
330
330
|
# View credit transaction history
|
|
331
331
|
transactions = client.account.transactions
|
|
332
332
|
transactions.each do |tx|
|
|
333
|
-
puts "#{tx
|
|
333
|
+
puts "#{tx.type}: #{tx.amount} credits - #{tx.description}"
|
|
334
334
|
end
|
|
335
335
|
|
|
336
336
|
# List API keys
|
|
337
337
|
keys = client.account.api_keys
|
|
338
338
|
keys.each do |key|
|
|
339
|
-
puts "#{key
|
|
339
|
+
puts "#{key.name}: #{key.prefix} (#{key.type})"
|
|
340
340
|
end
|
|
341
341
|
|
|
342
342
|
# Create a new API key
|
|
@@ -457,20 +457,17 @@ client.campaigns.delete(campaign.id)
|
|
|
457
457
|
Reusable message templates with variables. AI can also draft one for you.
|
|
458
458
|
|
|
459
459
|
```ruby
|
|
460
|
-
# Create / list / get
|
|
460
|
+
# Create / list / get. New templates start as drafts; publish to lock one for use.
|
|
461
461
|
template = client.templates.create(
|
|
462
462
|
name: "Order shipped",
|
|
463
|
-
|
|
464
|
-
is_published: true
|
|
463
|
+
text: "Hi {{name}}, order {{order_id}} has shipped!"
|
|
465
464
|
)
|
|
466
|
-
client.templates.list
|
|
465
|
+
client.templates.list[:templates].each { |t| puts "#{t.name} — #{t.status}" }
|
|
467
466
|
t = client.templates.get(template.id)
|
|
468
467
|
|
|
469
|
-
# Update, publish
|
|
470
|
-
client.templates.update(template.id,
|
|
468
|
+
# Update (drafts only), publish, delete
|
|
469
|
+
client.templates.update(template.id, text: "Hi {{name}}, your order is on the way!")
|
|
471
470
|
client.templates.publish(template.id)
|
|
472
|
-
client.templates.unpublish(template.id)
|
|
473
|
-
client.templates.clone(template.id, name: "Order shipped (copy)")
|
|
474
471
|
client.templates.delete(template.id)
|
|
475
472
|
|
|
476
473
|
# Generate a template with AI
|
|
@@ -745,6 +742,177 @@ status = client.links.update(link.code, disabled: true)
|
|
|
745
742
|
puts status.disabled?
|
|
746
743
|
```
|
|
747
744
|
|
|
745
|
+
## WhatsApp
|
|
746
|
+
|
|
747
|
+
Connect a number you own to WhatsApp, create Meta-reviewed message
|
|
748
|
+
templates, and send via `client.messages.send(channel: "whatsapp", ...)`.
|
|
749
|
+
Connecting is a one-time $19 setup (no monthly fee) and always ends with a
|
|
750
|
+
human step: the signup returns a connect URL a person must open in a
|
|
751
|
+
browser and log in with Facebook to link their WhatsApp Business Account.
|
|
752
|
+
|
|
753
|
+
Free-form text and media only deliver inside a 24-hour customer-service
|
|
754
|
+
window (opened by the recipient messaging you); an approved template works
|
|
755
|
+
anytime. Templates are reviewed by Meta (typically 24-48h) and categorized
|
|
756
|
+
as authentication, utility, or marketing — pricing follows the category and
|
|
757
|
+
destination country. Note: Meta has paused marketing template delivery to
|
|
758
|
+
US (+1) numbers.
|
|
759
|
+
|
|
760
|
+
```ruby
|
|
761
|
+
# 1. Connect a number ($19 one-time; a human must open the connect URL)
|
|
762
|
+
signup = client.whatsapp.signup.create(phone_number: "+15559876543")
|
|
763
|
+
puts "Have your user open: #{signup.connect_url}"
|
|
764
|
+
|
|
765
|
+
# 2. Poll until active
|
|
766
|
+
status = client.whatsapp.signup.get(signup.id)
|
|
767
|
+
puts status.failure_reasons if status.failed?
|
|
768
|
+
|
|
769
|
+
# List your connected senders
|
|
770
|
+
client.whatsapp.senders.list[:senders].each do |s|
|
|
771
|
+
puts "#{s.phone_number} (#{s.display_name || 'no name yet'}) — #{s.status}"
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
# Read and update a sender's business profile (what recipients see when
|
|
775
|
+
# they open your details in WhatsApp)
|
|
776
|
+
profile = client.whatsapp.senders.get_profile("+15559876543")
|
|
777
|
+
puts profile.display_name
|
|
778
|
+
puts profile.about
|
|
779
|
+
|
|
780
|
+
client.whatsapp.senders.update_profile(
|
|
781
|
+
"+15559876543",
|
|
782
|
+
about: "Fresh roasted coffee, delivered.", # max 139 chars
|
|
783
|
+
description: "Small-batch roaster shipping nationwide.", # max 512 chars
|
|
784
|
+
website: "https://acme.example"
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
# 3. Create a template (Meta reviews it, usually 24-48h)
|
|
788
|
+
template = client.whatsapp.templates.create(
|
|
789
|
+
sender: "+15559876543",
|
|
790
|
+
name: "order_shipped",
|
|
791
|
+
language: "en_US",
|
|
792
|
+
category: "UTILITY",
|
|
793
|
+
body: "Hi {{1}}, your order {{2}} has shipped!",
|
|
794
|
+
examples: { "1" => "Sam", "2" => "#4821" }
|
|
795
|
+
)
|
|
796
|
+
puts template.status # "PENDING"
|
|
797
|
+
|
|
798
|
+
# List, edit-and-resubmit (the recovery path for rejections), or delete
|
|
799
|
+
client.whatsapp.templates.list[:templates].each { |t| puts "#{t.name} — #{t.status}" }
|
|
800
|
+
client.whatsapp.templates.update(template.id, body: "Hi {{1}}, order {{2}} is on its way!",
|
|
801
|
+
examples: { "1" => "Sam", "2" => "#4821" })
|
|
802
|
+
client.whatsapp.templates.delete(template.id)
|
|
803
|
+
|
|
804
|
+
# 4. Send — free-form inside an open 24h window, template anytime
|
|
805
|
+
window = client.whatsapp.window(from: "+15559876543", to: "+15551234567")
|
|
806
|
+
if window.open?
|
|
807
|
+
client.messages.send(
|
|
808
|
+
channel: "whatsapp",
|
|
809
|
+
to: "+15551234567",
|
|
810
|
+
from: "+15559876543",
|
|
811
|
+
text: "Your table is ready!"
|
|
812
|
+
)
|
|
813
|
+
else
|
|
814
|
+
message = client.messages.send(
|
|
815
|
+
channel: "whatsapp",
|
|
816
|
+
to: "+15551234567",
|
|
817
|
+
from: "+15559876543",
|
|
818
|
+
template: {
|
|
819
|
+
name: "order_shipped",
|
|
820
|
+
language: "en_US",
|
|
821
|
+
variables: { "1" => "Acme Inc", "2" => "#4821" }
|
|
822
|
+
}
|
|
823
|
+
)
|
|
824
|
+
puts message.whatsapp.kind # "template"
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
# Media with a caption (also window-bound; one attachment per message)
|
|
828
|
+
client.messages.send(
|
|
829
|
+
channel: "whatsapp",
|
|
830
|
+
to: "+15551234567",
|
|
831
|
+
from: "+15559876543",
|
|
832
|
+
text: "Here is your receipt",
|
|
833
|
+
media_urls: ["https://example.com/receipt.pdf"]
|
|
834
|
+
)
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
## RCS
|
|
838
|
+
|
|
839
|
+
Send branded rich messages — text with suggested replies and actions, or
|
|
840
|
+
rich cards with an image and buttons — through your workspace's RCS agent
|
|
841
|
+
by passing `channel: "rcs"` to `client.messages.send`. Delivery is
|
|
842
|
+
per-recipient: not every device or network supports RCS. Text sends fall
|
|
843
|
+
back to plain SMS automatically (billed as SMS) unless you disable the
|
|
844
|
+
fallback; rich cards have no SMS form and only deliver to RCS-capable
|
|
845
|
+
recipients.
|
|
846
|
+
|
|
847
|
+
The RCS channel is being rolled out gradually and is not yet generally
|
|
848
|
+
available; until it is enabled for your account the endpoints read as
|
|
849
|
+
absent and calls raise `Sendly::NotFoundError` (HTTP 404). RCS sends and
|
|
850
|
+
capability checks require a live API key. RCS agents are registered by
|
|
851
|
+
Sendly for your brand — contact support to set one up.
|
|
852
|
+
|
|
853
|
+
```ruby
|
|
854
|
+
# Discover your RCS agents ("testing" reaches invited test devices only;
|
|
855
|
+
# "approved" reaches everyone). Pass agent_id on sends and capability
|
|
856
|
+
# checks when your workspace has more than one agent.
|
|
857
|
+
client.rcs.agents.list[:agents].each do |a|
|
|
858
|
+
puts "#{a.name} — #{a.status}#{a.sendable? ? ' (sendable)' : ''}"
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
# Pre-flight: can this recipient receive RCS?
|
|
862
|
+
capability = client.rcs.capability(to: "+15551234567")
|
|
863
|
+
puts capability.capable? ? "RCS" : "would fall back to SMS"
|
|
864
|
+
|
|
865
|
+
# Text with suggested replies and actions. Nested suggestion and card
|
|
866
|
+
# hashes are passed through verbatim, so they use the camelCase keys the
|
|
867
|
+
# API expects.
|
|
868
|
+
message = client.messages.send(
|
|
869
|
+
channel: "rcs",
|
|
870
|
+
to: "+15551234567",
|
|
871
|
+
text: "Your order has shipped! Want live updates?",
|
|
872
|
+
suggestions: [
|
|
873
|
+
{ reply: { text: "Yes, notify me", postbackData: "notify_yes" } },
|
|
874
|
+
{ action: { text: "Track order", postbackData: "track",
|
|
875
|
+
url: "https://acme.example/orders/4821" } }
|
|
876
|
+
]
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
# The response discloses which channel delivered
|
|
880
|
+
puts message.channel # "rcs", or "sms" when it fell back
|
|
881
|
+
if message.fell_back?
|
|
882
|
+
# Delivered as plain SMS (billed as SMS). Suggestions have no SMS form
|
|
883
|
+
# and were dropped — message.rcs.suggestions_dropped is true.
|
|
884
|
+
else
|
|
885
|
+
puts message.rcs.kind # "text" or "card"
|
|
886
|
+
puts message.rcs.agent_name # the brand name recipients see
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
# Rich card (RCS-capable recipients only — cards have no SMS form)
|
|
890
|
+
client.messages.send(
|
|
891
|
+
channel: "rcs",
|
|
892
|
+
to: "+15551234567",
|
|
893
|
+
card: {
|
|
894
|
+
title: "Spring collection",
|
|
895
|
+
description: "New arrivals are in - take a look.",
|
|
896
|
+
mediaUrl: "https://example.com/spring.jpg", # public JPEG, PNG, or GIF
|
|
897
|
+
orientation: "vertical", # or "horizontal"
|
|
898
|
+
suggestions: [
|
|
899
|
+
{ action: { text: "Shop now", postbackData: "shop",
|
|
900
|
+
url: "https://acme.example/spring" } }
|
|
901
|
+
]
|
|
902
|
+
}
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
# Opt out of the SMS fallback — the send raises Sendly::ValidationError
|
|
906
|
+
# (HTTP 422 rcs_not_supported_for_recipient) when the recipient can't
|
|
907
|
+
# receive RCS
|
|
908
|
+
client.messages.send(
|
|
909
|
+
channel: "rcs",
|
|
910
|
+
to: "+15551234567",
|
|
911
|
+
text: "RCS or nothing",
|
|
912
|
+
fallback_to_sms: false
|
|
913
|
+
)
|
|
914
|
+
```
|
|
915
|
+
|
|
748
916
|
## Error Handling
|
|
749
917
|
|
|
750
918
|
```ruby
|
|
@@ -893,7 +1061,8 @@ Full enterprise docs: [sendly.live/docs/enterprise](https://sendly.live/docs/ent
|
|
|
893
1061
|
## Requirements
|
|
894
1062
|
|
|
895
1063
|
- Ruby 3.0+
|
|
896
|
-
|
|
1064
|
+
|
|
1065
|
+
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
1066
|
|
|
898
1067
|
## License
|
|
899
1068
|
|
|
@@ -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
|
data/lib/sendly/client.rb
CHANGED
|
@@ -178,6 +178,20 @@ module Sendly
|
|
|
178
178
|
@links ||= LinksResource.new(self)
|
|
179
179
|
end
|
|
180
180
|
|
|
181
|
+
# Access the WhatsApp resource
|
|
182
|
+
#
|
|
183
|
+
# @return [Sendly::WhatsAppResource]
|
|
184
|
+
def whatsapp
|
|
185
|
+
@whatsapp ||= WhatsAppResource.new(self)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Access the RCS resource
|
|
189
|
+
#
|
|
190
|
+
# @return [Sendly::RcsResource]
|
|
191
|
+
def rcs
|
|
192
|
+
@rcs ||= RcsResource.new(self)
|
|
193
|
+
end
|
|
194
|
+
|
|
181
195
|
# Make a GET request
|
|
182
196
|
#
|
|
183
197
|
# @param path [String] API path
|
|
@@ -189,11 +203,21 @@ module Sendly
|
|
|
189
203
|
|
|
190
204
|
# Make a POST request
|
|
191
205
|
#
|
|
206
|
+
# Every POST carries an Idempotency-Key header. By default the client
|
|
207
|
+
# generates one per logical request ("sendly-ruby-retry-<uuid>") so the
|
|
208
|
+
# server can dedupe the client's own retries; pass +idempotency_key+ to
|
|
209
|
+
# supply your own (1-255 printable ASCII characters) and extend that
|
|
210
|
+
# protection across process restarts, or +auto_idempotency_key: false+
|
|
211
|
+
# to skip auto-generation for endpoints that dedupe by other means.
|
|
212
|
+
#
|
|
192
213
|
# @param path [String] API path
|
|
193
214
|
# @param body [Hash] Request body
|
|
215
|
+
# @param idempotency_key [String, nil] Caller-supplied idempotency key (optional)
|
|
216
|
+
# @param auto_idempotency_key [Boolean] Auto-generate a key when none is supplied (default: true)
|
|
194
217
|
# @return [Hash] Response body
|
|
195
|
-
def post(path, body = {})
|
|
196
|
-
request(:post, path, body: body
|
|
218
|
+
def post(path, body = {}, idempotency_key: nil, auto_idempotency_key: true)
|
|
219
|
+
request(:post, path, body: body, idempotency_key: idempotency_key,
|
|
220
|
+
auto_idempotency_key: auto_idempotency_key)
|
|
197
221
|
end
|
|
198
222
|
|
|
199
223
|
# Make a PATCH request
|
|
@@ -257,11 +281,15 @@ module Sendly
|
|
|
257
281
|
# @param file [String, IO] File path or IO object
|
|
258
282
|
# @param content_type [String] MIME type of the file
|
|
259
283
|
# @param filename [String] Name for the uploaded file
|
|
284
|
+
# @param idempotency_key [String, nil] Caller-supplied idempotency key (optional)
|
|
260
285
|
# @return [Hash] Response body
|
|
261
|
-
def post_multipart(path, file, content_type: "image/jpeg", filename: "upload.jpg")
|
|
286
|
+
def post_multipart(path, file, content_type: "image/jpeg", filename: "upload.jpg", idempotency_key: nil)
|
|
262
287
|
uri = build_uri(path, {})
|
|
263
288
|
http = build_http(uri)
|
|
264
289
|
|
|
290
|
+
explicit_key = normalize_idempotency_key(idempotency_key)
|
|
291
|
+
key = explicit_key || generate_idempotency_key
|
|
292
|
+
|
|
265
293
|
boundary = "SendlyRuby#{SecureRandom.hex(16)}"
|
|
266
294
|
|
|
267
295
|
file_data = file.is_a?(String) ? File.binread(file) : file.read
|
|
@@ -283,6 +311,7 @@ module Sendly
|
|
|
283
311
|
|
|
284
312
|
attempt = 0
|
|
285
313
|
begin
|
|
314
|
+
req["Idempotency-Key"] = key
|
|
286
315
|
response = http.request(req)
|
|
287
316
|
handle_response(response)
|
|
288
317
|
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
@@ -299,6 +328,10 @@ module Sendly
|
|
|
299
328
|
rescue ServerError => e
|
|
300
329
|
attempt += 1
|
|
301
330
|
if attempt <= max_retries
|
|
331
|
+
# A 5xx response may be cached under the key server-side, so an
|
|
332
|
+
# auto-generated key is rotated to let the retry re-execute.
|
|
333
|
+
# Caller-supplied keys are never rotated.
|
|
334
|
+
key = generate_idempotency_key if explicit_key.nil?
|
|
302
335
|
sleep(2 ** attempt)
|
|
303
336
|
retry
|
|
304
337
|
end
|
|
@@ -306,6 +339,13 @@ module Sendly
|
|
|
306
339
|
end
|
|
307
340
|
end
|
|
308
341
|
|
|
342
|
+
# Generate an idempotency key for a logical request. Reused across retry
|
|
343
|
+
# attempts so the server can recognize a retry of a POST that already
|
|
344
|
+
# reached it and return the original result instead of executing again.
|
|
345
|
+
def generate_idempotency_key
|
|
346
|
+
"sendly-ruby-retry-#{SecureRandom.uuid}"
|
|
347
|
+
end
|
|
348
|
+
|
|
309
349
|
private
|
|
310
350
|
|
|
311
351
|
def validate_api_key!
|
|
@@ -316,13 +356,19 @@ module Sendly
|
|
|
316
356
|
end
|
|
317
357
|
end
|
|
318
358
|
|
|
319
|
-
def request(method, path, params: {}, body: nil, unversioned: false
|
|
359
|
+
def request(method, path, params: {}, body: nil, unversioned: false, idempotency_key: nil,
|
|
360
|
+
auto_idempotency_key: true)
|
|
320
361
|
uri = build_uri(path, params, unversioned: unversioned)
|
|
321
362
|
http = build_http(uri)
|
|
322
363
|
req = build_request(method, uri, body)
|
|
323
364
|
|
|
365
|
+
explicit_key = normalize_idempotency_key(idempotency_key)
|
|
366
|
+
key = explicit_key
|
|
367
|
+
key = generate_idempotency_key if key.nil? && method == :post && auto_idempotency_key
|
|
368
|
+
|
|
324
369
|
attempt = 0
|
|
325
370
|
begin
|
|
371
|
+
req["Idempotency-Key"] = key if key
|
|
326
372
|
response = http.request(req)
|
|
327
373
|
handle_response(response)
|
|
328
374
|
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
@@ -339,6 +385,10 @@ module Sendly
|
|
|
339
385
|
rescue ServerError => e
|
|
340
386
|
attempt += 1
|
|
341
387
|
if attempt <= max_retries
|
|
388
|
+
# A 5xx response may be cached under the key server-side, so an
|
|
389
|
+
# auto-generated key is rotated to let the retry re-execute.
|
|
390
|
+
# Caller-supplied keys are never rotated.
|
|
391
|
+
key = generate_idempotency_key if key && explicit_key.nil?
|
|
342
392
|
sleep(2 ** attempt) # Exponential backoff
|
|
343
393
|
retry
|
|
344
394
|
end
|
|
@@ -346,6 +396,23 @@ module Sendly
|
|
|
346
396
|
end
|
|
347
397
|
end
|
|
348
398
|
|
|
399
|
+
# Validate and normalize a caller-supplied idempotency key. Empty and
|
|
400
|
+
# whitespace-only values are treated as absent (auto-generation still
|
|
401
|
+
# applies); invalid values fail fast instead of surfacing later as an
|
|
402
|
+
# API error.
|
|
403
|
+
def normalize_idempotency_key(key)
|
|
404
|
+
return nil if key.nil?
|
|
405
|
+
|
|
406
|
+
trimmed = key.to_s.strip
|
|
407
|
+
return nil if trimmed.empty?
|
|
408
|
+
|
|
409
|
+
if trimmed.length > 255 || !trimmed.match?(/\A[\x20-\x7E]+\z/)
|
|
410
|
+
raise ValidationError, "Idempotency key must be 1-255 printable ASCII characters"
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
trimmed
|
|
414
|
+
end
|
|
415
|
+
|
|
349
416
|
def build_uri(path, params, unversioned: false)
|
|
350
417
|
base = unversioned ? api_origin : base_url
|
|
351
418
|
url = "#{base}#{path}"
|
data/lib/sendly/enterprise.rb
CHANGED
|
@@ -505,6 +505,8 @@ module Sendly
|
|
|
505
505
|
req["User-Agent"] = "sendly-ruby/#{Sendly::VERSION}"
|
|
506
506
|
req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
|
|
507
507
|
req["X-Organization-Id"] = @client.organization_id if @client.organization_id
|
|
508
|
+
# Single-use auto key (this path has no retry loop).
|
|
509
|
+
req["Idempotency-Key"] = @client.generate_idempotency_key
|
|
508
510
|
req.body = body_parts.join
|
|
509
511
|
|
|
510
512
|
response = http.request(req)
|