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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5da119e52e96f793ddd86099f4f49ef94a5dc50965d480c84e23a43e5d0fa3c2
4
- data.tar.gz: f1fd1ae9ba13230e5a939594e3b5049bae6251daa064d5399cfe4a23b4937637
3
+ metadata.gz: c474ade97511ceecadbff15268b602edb67953395417996e9c1ac8b7626e68ff
4
+ data.tar.gz: b034a8b316eab9f85e98de49e31db3045326662beeefbafb306844302a147037
5
5
  SHA512:
6
- metadata.gz: 0a2f993fa684453f88a91144800c2808bcaaad51cbccbc88a982908f8b6ec6d6b7905a24374b1c56d6b1b6b8bb9c8b65a7004133e0f0859a33873be8c4dc5933
7
- data.tar.gz: e8d9bb57bbaee7a50bc13ae7237267eb1ec3a3237814be3f9351ff7d43d8ddc9174b663dea7e23b6b02c1c36b2c36930f432c7eb2ac7b493710a458353ce313a
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 Telnyx enum.
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.37.1)
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.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
@@ -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['type']}: #{tx['amount']} credits - #{tx['description']}"
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['name']}: #{key['prefix']}*** (#{key['type']})"
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
- body: "Hi {{name}}, order #{{order_id}} has shipped!",
464
- is_published: true
489
+ text: "Hi {{name}}, order {{order_id}} has shipped!"
465
490
  )
466
- client.templates.list(type: "custom")[:templates].each { |t| puts t.name }
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/unpublish, clone
470
- client.templates.update(template.id, body: "Hi {{name}}, your order is on the way!")
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
- - Faraday 2.0+
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
- # @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