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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43ce471688134e41cbed23f9d6b8bcc82895c867a4f42340120f670424d5a5f7
4
- data.tar.gz: 628de06e8a2f2a25fc4837c94cb3bb651dc877f6d27ad8967af8b732d97f0a30
3
+ metadata.gz: 76b3722f8971a04363ce5a5ea47d61eba59db05698652f21cd5ab347c24c74c1
4
+ data.tar.gz: 478881a38dac65c6f9696d0de53114fadc10651152304542519616794ed64bfe
5
5
  SHA512:
6
- metadata.gz: a012fb951c1c7e1f000e83e031d8c35eb3145bcdef3ce7ddc2eacc400263dae269223c4631e12f2db621ea0aa3a887142c18614773fcb5f577eee09bda0a1d92
7
- data.tar.gz: f01118db4ee6fd181b148a2d2c1d7ea551b14c2e748cc0d01cf16cf5e097ba5718e53b943423ded434c071f17f2f27adbb63ed5064312c2d5ddebcdb0b22fa0e
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 Telnyx enum.
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.37.0)
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.2)
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['type']}: #{tx['amount']} credits - #{tx['description']}"
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['name']}: #{key['prefix']}*** (#{key['type']})"
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
- body: "Hi {{name}}, order #{{order_id}} has shipped!",
464
- is_published: true
463
+ text: "Hi {{name}}, order {{order_id}} has shipped!"
465
464
  )
466
- client.templates.list(type: "custom")[:templates].each { |t| puts t.name }
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/unpublish, clone
470
- client.templates.update(template.id, body: "Hi {{name}}, your order is on the way!")
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
- - Faraday 2.0+
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
- # @return [void]
92
- def revoke_api_key(key_id)
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
- @client.delete("/account/keys/#{key_id}")
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}"
@@ -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)