sendly 3.38.0 → 3.40.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: 76b3722f8971a04363ce5a5ea47d61eba59db05698652f21cd5ab347c24c74c1
4
- data.tar.gz: 478881a38dac65c6f9696d0de53114fadc10651152304542519616794ed64bfe
3
+ metadata.gz: 91dd6ee6db6b506dea8d70b085fc6a069330e9ddca10b1de8e097b3db07889cd
4
+ data.tar.gz: 3114725bc7d45361b625ba7c7cfc88b6a6eee0672154b3b3daf46237cb475b53
5
5
  SHA512:
6
- metadata.gz: db802e6f2ac77bd0978de8a043b8a34488168e820e8b2e6ad3aca7033f43377b1524ccdee742077471486013d6f6e8607465b1fbaf68038dfb8d37d3f119915c
7
- data.tar.gz: 33103fdeb0adf5816c3ffcdc23c4107eb52cc814ed904ae2b9d64f314d8fb88bbf2f0e837c0d4bd66781903710c7ffab770da166e5372eebe79d8522c3ace7d8
6
+ metadata.gz: a8728f247769f3bbd8ad1713a86814eb419c944d64f06797ed94f858b543185d4c1baced35ba9aa910871b04a15e0348aece222a4e88de691e717df9b5542c01
7
+ data.tar.gz: ba2ad16077e383a0e37b7dee97ebada69cd3c1e815f6775afb677ab331a031da2f643666da3e95cc2b90e0dbff3478a6d35bc9a0364efd9b865c1ff5302f25fd
data/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # sendly (Ruby)
2
2
 
3
+ ## Unreleased
4
+
5
+ ### Breaking Changes
6
+
7
+ - **A webhook that is not a message is no longer presented as one.** `parse_event` built a `Sendly::WebhookMessageData` out of every `data.object`, whatever the event was. For an `rcs_*`, `whatsapp_*`, `call.*`, `brand.*`, `campaign.*`, `assignment.*`, `number.*`, `port*`, `contact*`, `conversation.*` or `draft.*` payload that dropped every field the event actually carried — `agent_id`, `stage`, `port_request_id`, `duration_secs` and the rest were unreachable — and filled the gaps with message fields that were never sent. `event.data` is now a `Sendly::WebhookObject`, a hash-like view of `data.object`: read a key with `[]` (String or Symbol), a reader method of the same name, or `to_h`. `event.message` is the message view and is `nil` for all of the above; `message.*` events are unchanged, and `event.data` is still the `WebhookMessageData` there.
8
+
9
+ ```ruby
10
+ # before — "" for every RCS event, and the agent was unreachable
11
+ event.data.from
12
+ # after
13
+ event.data[:agent_id] # => "bb22cc33-..."
14
+ event.message # => nil
15
+ ```
16
+
17
+ - **Absent fields are `nil` instead of a plausible-looking default.** `WebhookMessageData` defaulted `segments` to `1`, `credits_used` to `0`, `direction` to `"outbound"`, `from` to `""` and `id` to `""`, none of which a handler could tell from a real value. They are now `nil` when the payload did not carry them, and `data.key?(:segments)` says which case you are in. `WebhookVerificationData` loses the same kind of defaults (`delivery_status` `"queued"`, `attempts` `0`, `max_attempts` `3`), though nothing could reach that class before this release.
18
+
19
+ - **JSON `null` survives as `nil`.** `from` and `to` on a `call.*` event are `null` for every in-app call; `from` used to arrive as `""`. Code branching on `from.empty?` should branch on `nil` now.
20
+
21
+ - **`event.data.to_h` returns `data.object` as it arrived.** It used to return a compacted subset of the typed message fields, which dropped `text`, `metadata`, `media_urls`, `message_format` and `organization_id`, renamed `message_id` to `id`, and emitted the invented `segments`/`credits_used` defaults. `event.to_h[:data]` is the same hash. For a current-shape payload the familiar keys are all still there.
22
+
23
+ - **`id` is no longer filled from an unrelated `id` key.** `contact.auto_flagged` carries the contact under `id` and the message that failed under `message_id`, so `event.data.message_id` returned the *contact* id — a handler that marked that message failed acted on the wrong row. Contact events have no message view at all now; read the message with `event.data[:message_id]`.
24
+
25
+ ### Minor Changes
26
+
27
+ - **`Sendly::WebhookEvent#raw_object`** carries `data.object` exactly as it arrived, for every event type, and **`#object_as(klass)`** reads it as a type of your choosing (`event.object_as(AgentLive)`). `#object` is an alias for `raw_object`.
28
+
29
+ - **`Sendly::WebhookVerificationData` is reachable.** Nothing ever constructed it, and it read String keys while `parse_event` symbolizes names, so it could not have worked if anything had. `verification.*` events now build one, as `event.verification` and `event.data`, and every reader takes String or Symbol keys.
30
+
31
+ - **`Sendly::WebhookEvent` gains `#message?` and `#verification?`** for the two cases that have a typed view.
32
+
33
+ - **`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.
34
+
35
+
36
+ - **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`.
37
+
38
+ ```ruby
39
+ dossier = client.rcs.dossier.get
40
+ brand = client.rcs.brands.create(**dossier.brand)
41
+ agent = client.rcs.agents.create(brand_id: brand.id, display_name: "Acme Coffee",
42
+ use_case: "MULTI_USE",
43
+ basics: { logo_url: "https://acme.example/rcs/logo.png" })
44
+ client.rcs.agents.submit(agent.id, idempotency_key: "rcs-submit-#{agent.id}")
45
+ ```
46
+
47
+ - **`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.
48
+
49
+ - **`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.
50
+
3
51
  ## 3.38.0
4
52
 
5
53
  ### Minor Changes
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- sendly (3.38.0)
4
+ sendly (3.40.0)
5
5
  faraday (~> 2.0)
6
6
  faraday-retry (~> 2.0)
7
7
 
data/README.md CHANGED
@@ -280,8 +280,36 @@ 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
 
311
+ ### Managing endpoints
312
+
285
313
  ```ruby
286
314
  # Create a webhook endpoint
287
315
  webhook = client.webhooks.create(
@@ -314,6 +342,67 @@ rotation = client.webhooks.rotate_secret("whk_xxx")
314
342
  client.webhooks.delete("whk_xxx")
315
343
  ```
316
344
 
345
+ ### Receiving events
346
+
347
+ `Sendly::Webhooks.parse_event` verifies the signature and returns a
348
+ `Sendly::WebhookEvent`. Pass the raw request body — not a re-serialized hash,
349
+ which would no longer match the signature.
350
+
351
+ ```ruby
352
+ event = Sendly::Webhooks.parse_event(
353
+ request.raw_post,
354
+ request.headers["X-Sendly-Signature"],
355
+ ENV.fetch("SENDLY_WEBHOOK_SECRET"),
356
+ timestamp: request.headers["X-Sendly-Timestamp"]
357
+ )
358
+ ```
359
+
360
+ `event.raw_object` is `data.object` exactly as it arrived, for every event
361
+ type. `event.data` is a hash-like view of the same object: read a key with
362
+ `[]` (String or Symbol), a reader method of the same name, or `to_h`.
363
+
364
+ ```ruby
365
+ case event.type
366
+ when Sendly::Webhooks::EVENT_MESSAGE_DELIVERED
367
+ # message.* events also get a typed message view
368
+ puts "#{event.message.id} -> #{event.message.to}"
369
+ when Sendly::Webhooks::EVENT_RCS_AGENT_LIVE
370
+ puts event.data[:agent_id]
371
+ puts event.data.stage
372
+ when Sendly::Webhooks::EVENT_CALL_COMPLETED
373
+ puts event.data[:duration_secs]
374
+ end
375
+
376
+ # Or read data.object as a type of your own. A Struct or Data class is filled
377
+ # from the members it declares and ignores the rest of the payload, so a field
378
+ # added to the event later cannot break the call.
379
+ AgentLive = Struct.new(:agent_id, :name, :stage)
380
+ agent = event.object_as(AgentLive)
381
+ ```
382
+
383
+ Two things the SDK will not do, because both make a handler act on data that
384
+ was never sent:
385
+
386
+ - **Nothing is invented.** A field the payload did not carry is `nil`, and
387
+ `event.data.key?(:segments)` is `false`. `segments`, `credits_used`,
388
+ `direction`, `to` and `from` are no longer defaulted to `1`, `0`,
389
+ `"outbound"` and `""`.
390
+ - **`nil` stays `nil`.** An in-app `call.*` event carries `from` and `to` as
391
+ JSON `null`; they come back as `nil`, not `""`.
392
+
393
+ `event.message` is the message view and is `nil` for every event that is not a
394
+ message — `rcs_*`, `whatsapp_*`, `call.*`, `brand.*`, `campaign.*`,
395
+ `assignment.*`, `number.*`, `port*`, `contact*`, `conversation.*` and
396
+ `draft.*`, whose payloads are not message-shaped. `verification.*` events get
397
+ `event.verification`, a `Sendly::WebhookVerificationData`. `event.data` is the
398
+ typed view where one exists and a plain `Sendly::WebhookObject` otherwise, so
399
+ reading `data.object` works the same way for all of them, including an event
400
+ type this SDK predates.
401
+
402
+ Note that `contact.auto_flagged` carries the contact under `id` and the message
403
+ that failed under `message_id`; read the message with
404
+ `event.data[:message_id]`.
405
+
317
406
  ## Account & Credits
318
407
 
319
408
  ```ruby
@@ -846,9 +935,108 @@ recipients.
846
935
 
847
936
  The RCS channel is being rolled out gradually and is not yet generally
848
937
  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.
938
+ absent and calls raise `Sendly::NotFoundError` (HTTP 404: `not_found` on
939
+ sends and listing, `rcs_not_enabled` on registration). RCS sends and
940
+ capability checks require a live API key.
941
+
942
+ ### Registering an agent
943
+
944
+ Registration is self-serve, from the dashboard or the API. Draft the
945
+ brand (the business) and the agent (what recipients see), submit them for
946
+ Sendly's review, and once approved they go on to the carrier network for
947
+ brand verification and agent review. The agent then reaches invited test
948
+ devices only; when you have tested it, request launch and Sendly reviews
949
+ the campaign before sending the launch on to the carrier network. Watch
950
+ `customer_stage` (one of `Sendly::RcsRegistration::CUSTOMER_STAGES`) to
951
+ see where a registration is.
952
+
953
+ Registration reads need the `rcs:read` scope and writes `rcs:write`; test
954
+ and live keys both work, since drafting is not carrier-backed. Logo, hero
955
+ and call-to-action media must be public `https://` URLs: assets cannot be
956
+ uploaded over the API, only from the dashboard. Nested hashes (address,
957
+ contact, basics, campaign, testing) accept snake_case or camelCase keys.
958
+ Every write accepts `idempotency_key:`; `POST`s get an auto-generated key
959
+ as usual, while `PATCH` and `PUT` send one only when you pass it.
960
+
961
+ ```ruby
962
+ # Where is the registration at?
963
+ reg = client.rcs.registration.get
964
+ puts reg.stage # "draft" until something is submitted
965
+
966
+ # Seed the brand from details Sendly already holds (your 10DLC brand or
967
+ # toll-free verification), then fill in the rest
968
+ dossier = client.rcs.dossier.get
969
+ brand = client.rcs.brands.create(**dossier.brand) # dossier.source: "tendlc", "verification" or "none"
970
+ client.rcs.brands.update(brand.id,
971
+ display_name: "Acme Coffee",
972
+ legal_entity_type: "LIMITED_LIABILITY_COMPANY",
973
+ contact: { first_name: "Sam", last_name: "Lee", email: "sam@acme.example",
974
+ phone_number: "+15551234567" }
975
+ )
976
+
977
+ # Draft the agent. Media must be public https URLs.
978
+ agent = client.rcs.agents.create(
979
+ brand_id: brand.id,
980
+ display_name: "Acme Coffee",
981
+ use_case: "MULTI_USE",
982
+ basics: {
983
+ description: "Order updates and offers from Acme Coffee",
984
+ logo_url: "https://acme.example/rcs/logo.png",
985
+ hero_url: "https://acme.example/rcs/hero.png",
986
+ brand_color: "#5B3A29",
987
+ privacy_policy_url: "https://acme.example/privacy",
988
+ terms_and_conditions_url: "https://acme.example/terms",
989
+ phone_number: { number: "+15551234567", label: "Support" }
990
+ }
991
+ )
992
+
993
+ # Submit for Sendly's review. Pass your own key if you may retry: a replay
994
+ # returns the original response without submitting again.
995
+ agent = client.rcs.agents.submit(agent.id, idempotency_key: "rcs-submit-#{agent.id}")
996
+ puts agent.review_status # "awaiting_review"
997
+
998
+ # Later: poll, and act on a review note
999
+ agent = client.rcs.agents.get(agent.id)
1000
+ if agent.changes_requested?
1001
+ puts agent.review_note
1002
+ client.rcs.agents.update(agent.id, basics: { hero_url: "https://acme.example/rcs/hero-v2.png" })
1003
+ end
1004
+
1005
+ # Once the agent is in testing, invite devices, describe the campaign,
1006
+ # then request launch
1007
+ client.rcs.agents.set_test_devices(agent.id, devices: [
1008
+ { phone_number: "+15557654321", label: "Sam's Pixel" }
1009
+ ])
1010
+ client.rcs.agents.update(agent.id,
1011
+ campaign: {
1012
+ company_overview: "Specialty coffee roaster with three cafes in Austin",
1013
+ agent_overview: "Order updates, pickup alerts and support replies",
1014
+ interactions: [{ interaction_type: "TRANSACTIONAL_UPDATES",
1015
+ description: "Order and pickup status" }],
1016
+ message_examples: ["Your order #4821 has shipped!",
1017
+ "Your latte is ready for pickup at 5th St",
1018
+ "Reply HELP for support or STOP to opt out"],
1019
+ consent_settings: {
1020
+ opt_in_methods: [{ method_type: "WEBSITE", description: "Checkbox at checkout" }],
1021
+ call_to_action: "Get order updates by RCS",
1022
+ call_to_action_url: "https://acme.example/updates",
1023
+ double_opt_in: false,
1024
+ help_response: "Acme Coffee: reply STOP to opt out, or email help@acme.example",
1025
+ opt_out_response: "You're opted out of Acme Coffee updates."
1026
+ }
1027
+ }
1028
+ )
1029
+ client.rcs.agents.request_launch(agent.id, test_url: "https://acme.example/rcs-test")
1030
+ ```
1031
+
1032
+ Registration errors map onto the usual classes: `Sendly::NotFoundError`
1033
+ for `rcs_not_enabled` and `rcs_not_found`; `Sendly::ValidationError` for
1034
+ `rcs_us_only` and `rcs_invalid_content`, whose `field_errors` is the API's
1035
+ list of `{ "path", "message" }` hashes; and `Sendly::APIError` with
1036
+ `status_code` 409 for `rcs_field_locked`, `rcs_brand_not_verified` and
1037
+ `rcs_launch_not_ready`, or 403 for a key missing the scope.
1038
+
1039
+ ### Sending
852
1040
 
853
1041
  ```ruby
854
1042
  # Discover your RCS agents ("testing" reaches invited test devices only;
data/lib/sendly/client.rb CHANGED
@@ -222,20 +222,28 @@ module Sendly
222
222
 
223
223
  # Make a PATCH request
224
224
  #
225
+ # No Idempotency-Key is generated for a PATCH; pass +idempotency_key+
226
+ # to send one (1-255 printable ASCII characters).
227
+ #
225
228
  # @param path [String] API path
226
229
  # @param body [Hash] Request body
230
+ # @param idempotency_key [String, nil] Caller-supplied idempotency key (optional)
227
231
  # @return [Hash] Response body
228
- def patch(path, body = {})
229
- request(:patch, path, body: body)
232
+ def patch(path, body = {}, idempotency_key: nil)
233
+ request(:patch, path, body: body, idempotency_key: idempotency_key)
230
234
  end
231
235
 
232
236
  # Make a PUT request
233
237
  #
238
+ # No Idempotency-Key is generated for a PUT; pass +idempotency_key+
239
+ # to send one (1-255 printable ASCII characters).
240
+ #
234
241
  # @param path [String] API path
235
242
  # @param body [Hash] Request body
243
+ # @param idempotency_key [String, nil] Caller-supplied idempotency key (optional)
236
244
  # @return [Hash] Response body
237
- def put(path, body = {})
238
- request(:put, path, body: body)
245
+ def put(path, body = {}, idempotency_key: nil)
246
+ request(:put, path, body: body, idempotency_key: idempotency_key)
239
247
  end
240
248
 
241
249
  # Make a DELETE request
data/lib/sendly/errors.rb CHANGED
@@ -47,7 +47,9 @@ module Sendly
47
47
 
48
48
  # Raised when the request contains invalid parameters
49
49
  class ValidationError < Error
50
- # @return [Hash, nil] Field-specific validation errors
50
+ # @return [Array<Hash>, Hash, nil] Field-specific validation errors. For
51
+ # API responses this is the +errors+ list from the body when present,
52
+ # e.g. +[{ "path" => "brand.ein", "message" => "Enter a 9-digit EIN" }]+
51
53
  attr_reader :field_errors
52
54
 
53
55
  def initialize(message = "Validation failed", field_errors: nil, details: nil)
@@ -101,7 +103,7 @@ module Sendly
101
103
 
102
104
  case status
103
105
  when 400, 422
104
- ValidationError.new(message, details: details)
106
+ ValidationError.new(message, details: details, field_errors: body["errors"])
105
107
  when 401
106
108
  AuthenticationError.new(message)
107
109
  when 402