clicksend 1.1.0 → 1.2.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.
data/README.md CHANGED
@@ -64,7 +64,7 @@ message.message_id # => "1ABC3200-C38C-6308-BE4B-C7C51D01DCF0"
64
64
  | You want to… | Use |
65
65
  |---|---|
66
66
  | Send SMS from a Ruby app and track delivery and replies, with safe defaults, typed errors, instrumentation and a test fake | **this gem** |
67
- | Call the occasional ClickSend endpoint this gem doesn't wrap (price a message, cancel a scheduled one, list templates) with the same authentication, timeouts, retry rules and errors | **this gem's [`client.request`](#calling-other-clicksend-endpoints)** |
67
+ | Call the occasional ClickSend endpoint this gem doesn't wrap (price a message, list templates, view statistics) with the same authentication, timeouts, retry rules and errors | **this gem's [`client.request`](#calling-other-clicksend-endpoints)** |
68
68
  | Work with large parts of the API (email, campaigns, contacts, numbers, automations, subaccounts, and so on) with generated models for each | ClickSend's official SDK, [`clicksend_client`](https://rubygems.org/gems/clicksend_client) |
69
69
 
70
70
  The two gems can be used side by side ([namespaces differ](#using-it-alongside-the-official-sdk)).
@@ -183,6 +183,37 @@ Unknown keywords raise `ArgumentError`, so typos can't be silently ignored.
183
183
  > ClickSend currently pauses SMS containing URLs for new customers until URL messaging is
184
184
  > approved ([docs](https://developers.clicksend.com/docs/messaging/sms)).
185
185
 
186
+ ### Cancelling a scheduled message
187
+
188
+ > **Experimental in 1.2.** The successful answer (HTTP 200, `SUCCESS`) is covered by contract
189
+ > tests against ClickSend's published API description, but has **not been observed live**.
190
+ > ClickSend's free test number doesn't hold scheduled messages: in a live check (2026-10-06) a
191
+ > message scheduled an hour ahead showed as `Completed` in history within seconds, so the test
192
+ > number can't exercise a successful cancel. Cancelling those test-number messages answered
193
+ > HTTP 404 `NOT_FOUND`, which the gem raises as `Clicksend::NotFoundError`. The API may change in a
194
+ > minor release once a real cancellation has been observed.
195
+
196
+ ```ruby
197
+ message = client.sms.deliver(to: user.phone, body: "Your appointment is tomorrow", schedule: Time.now + 86_400)
198
+ client.sms.cancel(message.message_id) # => nil when ClickSend answers SUCCESS
199
+ ```
200
+
201
+ ClickSend documents only the successful case. In the live check, cancelling a message that was no
202
+ longer scheduled, cancelling the same ID twice and cancelling a random ID all answered the same
203
+ 404 `NOT_FOUND` ("Record not found."). So a `NotFoundError` doesn't tell you why: the ID may be
204
+ unknown, or the message may no longer be cancellable, and it does **not** mean the message was
205
+ sent. Nor does "no exception" prove the message won't go out. If it matters, for example before
206
+ you schedule a replacement, check `client.sms.history(message_id: message.message_id)` for the
207
+ status `"Cancelled"`.
208
+
209
+ `cancel` is not retried after a timeout or 5xx, because ClickSend documents no idempotency for it.
210
+ Such a failure raises an [`AmbiguousRequestError`](#when-a-sends-outcome-is-unknown): the message
211
+ may or may not have been cancelled. Calling `cancel` again can't send anything, but may raise even
212
+ if the first call worked; history tells you which.
213
+
214
+ There is deliberately no wrapper for `PUT /v3/sms/cancel-all`: without a `custom_string` filter it
215
+ cancels every scheduled message on the account.
216
+
186
217
  ### Many messages in one request
187
218
 
188
219
  ```ruby
@@ -215,7 +246,7 @@ accepted. The gem never retries such a send. It raises the error extended with
215
246
 
216
247
  ```ruby
217
248
  begin
218
- client.sms.deliver(to: user.phone, body: text, custom_string: "otp:#{attempt.id}")
249
+ client.sms.deliver(to: user.phone, body: text, custom_string: "otp:#{otp.id}")
219
250
  rescue Clicksend::AmbiguousRequestError => e
220
251
  e.class # => Clicksend::TimeoutError (or ServerError, ConnectionError, MalformedResponseError,
221
252
  # or any APIError ClickSend reported inside a 2xx answer)
@@ -231,17 +262,27 @@ message ClickSend refused) are not ambiguous. Apart from the refused connection,
231
262
  inference from ClickSend's documentation and observed behaviour, not a guarantee. An error ClickSend reports inside a 2xx
232
263
  answer is ambiguous whatever its code, because that behaviour is undocumented.
233
264
 
234
- To check, look the message up in [history](#message-history) by recipient and match your own
235
- `custom_string`:
265
+ To check, search [history](#message-history) for your recipient and `custom_string`:
236
266
 
237
267
  ```ruby
238
- sent = client.sms.history(to: user.phone, date_from: started_at - 60)
239
- .auto_paging_each.find { |record| record.custom_string == "otp:#{attempt.id}" }
268
+ records = client.sms.search_history(to: user.phone, custom_string: "otp:#{otp.id}", sent_after: started_at)
269
+ records.any? # true: ClickSend accepted it (records.first.status says how far it got)
270
+ # false: nothing is known yet. This is NOT proof that it wasn't sent.
240
271
  ```
241
272
 
242
- ClickSend doesn't document how soon a sent message appears in history, so **a message missing
243
- from history is not proof that it wasn't sent**. Whether to resend is your decision: for a login
244
- code, letting the user request another is usually safer than resending automatically.
273
+ `search_history` asks for the recipient's history (`q=to:`) from five minutes before
274
+ `sent_after` (ClickSend doesn't say whether its date filters are inclusive or which clock they
275
+ use), reads every page, and keeps only outbound rows whose `to` and `custom_string` are exactly
276
+ yours: `custom_string` isn't a filter ClickSend offers, and it calls a filter value a "text or
277
+ keyword", not an exact match. `to` must be E.164 (`"+61411111111"`), the form history stores.
278
+ Each page of 100 rows is one request against ClickSend's undocumented rate limits.
279
+
280
+ ClickSend doesn't document how soon a sent message appears in history, keeps history for about
281
+ four months, and can de-identify recipients on request, so **an empty result is not proof that
282
+ nothing was sent**. Whether to resend is your decision: for a login code, letting the user
283
+ request another is usually safer than resending automatically. Use a `custom_string` that names
284
+ the message you meant to send (here the OTP record), not one try at sending it: a job that runs
285
+ again reuses it, so a match from an earlier try counts.
245
286
 
246
287
  ## Delivery receipts and replies
247
288
 
@@ -308,38 +349,68 @@ end
308
349
  works out which of the two it was given. Pass the body parameters, not the route ones, so your
309
350
  secret isn't kept in `#raw`.
310
351
 
311
- > **Experimental.** ClickSend doesn't document the push format, and no real push has been captured
312
- > for this gem yet: the field names come from ClickSend's polling API and archived docs. The
313
- > `Clicksend::Webhook` API may change in a minor release.
314
-
315
- **ClickSend documents no way to authenticate webhooks.** There is no documented signature, shared
316
- secret or HMAC. ClickSend's current documentation lists no source IP addresses; archived help pages
317
- (around 2019–2021, no longer published) listed some and said pushes come from a fixed pool. That
318
- list can't be checked against today's infrastructure, so this gem doesn't support or recommend IP
319
- allowlisting. Treat anyone who learns the URL as able to send a fake receipt. This gem therefore
320
- offers no "verify" method. Instead:
321
- - put an unguessable secret in the URL, compare it in constant time, and use HTTPS. A secret in the
322
- path appears in access logs (Rails logs the path, and proxies and APM tools often keep it), so
323
- restrict who can read them, and filter `body`, `from` and `to` with `filter_parameter_logging`;
352
+ > **Experimental.** ClickSend's current docs don't define the push format, and no real push has
353
+ > been captured for this gem yet. The field names come from ClickSend's polling API, which an
354
+ > archived ClickSend help article and ClickSend's own n8n and Power Automate integrations agree
355
+ > with. The `Clicksend::Webhook` API may change in a minor release.
356
+
357
+ **ClickSend's current docs describe no way to authenticate webhooks**: no signature, HMAC or shared
358
+ secret. An archived help article listed six source IP addresses, but it is no longer published and
359
+ was last updated years ago, so don't build an allowlist on it. Treat anyone who learns the URL as
360
+ able to send a fake receipt. This gem therefore offers no "verify" method. Instead:
361
+ - put an unguessable secret in the URL, compare it in constant time, and use HTTPS (archived
362
+ ClickSend help says the certificate chain must be valid). A secret in the path appears in access
363
+ logs: Rails' request log shows the path as is (Rails 8.1 filters only query parameters, such as
364
+ `?token=`), and proxies and APM tools often keep the full URL. Restrict who can read them, and
365
+ add the personal fields (`body`, `message`, `from`, `to`, `sms`, `originalsenderid`,
366
+ `original_body`, `originalmessage`) to `filter_parameters`;
367
+ - to rotate the secret, accept the old and the new one until every rule uses the new URL;
324
368
  - treat a push as a hint. A receipt can probably be confirmed with `client.sms.receipt(message_id)`
325
369
  (not yet verified for an account with only URL rules), at one API call per check. An
326
370
  inbound message can't be fetched by its ID through any wrapped or verified endpoint; the closest
327
371
  check is `client.sms.history(from: number)`. Be careful acting on unconfirmed replies such as
328
372
  "STOP";
329
- - handle pushes idempotently. Several rules can match, and (according to ClickSend's archived docs)
330
- a non-200 answer is retried every 10 minutes, up to 10 times. Key inbound messages on
331
- `message_id`. Key receipts on `message_id` **and** `status_code`: ClickSend's gateway codes include
332
- states that aren't final (200, 300), so one message can legitimately produce more than one
333
- receipt, and deduplicating on `message_id` alone could discard the final 201 or 301. When
334
- receipts for a message disagree, prefer a final code;
335
- - answer 200 quickly and do the work in a job.
373
+ - handle pushes idempotently. Several rules can match (up to 10 per inbound message, and
374
+ integrations such as Zapier create their own rules), and a non-200 or slow answer is retried:
375
+ ClickSend's archived pages say either every 10 minutes up to 10 times, or with backoff over hours.
376
+ An inbound `message_id` is unique. A sent message may get more than one receipt (the pending
377
+ codes 200 and 300 can change), so key receipts on `message_id` and `status_code`, and don't let a
378
+ pending receipt overwrite a final one (`delivered?` or `failed?`);
379
+ - answer 200 quickly and do the work in a job. If the job parses or confirms the push, add
380
+ `discard_on Clicksend::Webhook::InvalidPayload` below any `retry_on Clicksend::Error`: a payload
381
+ that can't be parsed won't parse on a retry.
382
+
383
+ ```ruby
384
+ # Two secrets during a rotation; ActiveSupport's secure_compare also handles different lengths.
385
+ def valid_secret?(given)
386
+ secrets = Rails.application.credentials.clicksend_webhook_secrets # e.g. [new, old]
387
+ secrets.any? { |secret| ActiveSupport::SecurityUtils.secure_compare(given.to_s, secret) }
388
+ end
389
+
390
+ # Deduplicating receipts in a job: one row per message, final statuses win.
391
+ # (Unique index on sms_deliveries.message_id.)
392
+ def record(receipt)
393
+ delivery = SmsDelivery.create_or_find_by!(message_id: receipt.message_id)
394
+ return if delivery.final? && receipt.pending? # final?: your own check for status_code 201 or 301
395
+
396
+ delivery.update!(status_code: receipt.status_code, reported_at: receipt.reported_at)
397
+ end
398
+ ```
336
399
 
337
400
  Inbound rules post form fields by default, or use a query string (`webhook_type: "get"`) or JSON
338
401
  (`"json"`); for JSON, pass `JSON.parse(request.raw_post)` or Rails' parsed body parameters. Receipt
339
- pushes are form-encoded according to ClickSend's archived docs. The parsers use the field names of
340
- the polling API, which match the archived push documentation, and reject payloads with too many
341
- fields, oversized or non-UTF-8 values, or nested values in the fields they read. Pass a plain
342
- Hash of the body parameters rather than Rails' `params`, which also holds route parameters.
402
+ pushes are form-encoded according to ClickSend's archived help. According to the same sources,
403
+ pushes also carry `user_id` and legacy duplicates (`message`, `sms`, `originalsenderid`,
404
+ `messageid`, `customstring`, ...), which are kept in `#raw`, and a JSON push may send `timestamp`
405
+ and `user_id` as numbers; either form is accepted. The parsers reject payloads with too many fields, oversized
406
+ or non-UTF-8 values, or nested values in the fields they read. Pass a plain Hash of the body
407
+ parameters rather than Rails' `params`, which also holds route parameters.
408
+
409
+ Some things a receiver should expect, according to ClickSend's archived help: the same receipt
410
+ format is used for voice, email and fax receipts (check `receipt.message_type` if those rules share
411
+ your URL), and an inbound MMS arrives through the SMS inbound rules with its attachment as a link
412
+ that expires after 7 days. To see a push without sending an SMS, the dashboard's **Add Test Reply**
413
+ button on a rule posts an example to its URL.
343
414
 
344
415
  ## Message history
345
416
 
@@ -356,7 +427,7 @@ For this endpoint ClickSend documents a single `q=field:value` filter. Its gener
356
427
  documentation also describes several comma-separated fields with an `operator`, but not for history,
357
428
  and that hasn't been verified here. So `history` takes at most one of `to:`, `from:`, `status:` and
358
429
  `message_id:`, plus `date_from:`, `date_to:` and `order:` (`:asc` or `:desc`). `custom_string` isn't
359
- a documented filter; match it yourself.
430
+ a documented filter; match it yourself, or use [`search_history`](#when-a-sends-outcome-is-unknown).
360
431
 
361
432
  ## Account balance
362
433
 
@@ -485,8 +556,14 @@ check whether that library retries requests by itself.
485
556
 
486
557
  ## Background jobs
487
558
 
488
- Job frameworks retry failed jobs, which can undo the gem's care about duplicates. Two rules keep a
489
- send job as safe as the gem can make it:
559
+ Job runners retry failed jobs and re-run interrupted ones, and either can turn a send whose outcome
560
+ is unknown into a second SMS. The measurements in this section come from experiments with
561
+ ActiveJob 8.1.4, Sidekiq 8.1.7 and ActiveRecord 8.1.4 (SQLite) against local stand-ins for
562
+ ClickSend, not from ClickSend itself.
563
+
564
+ ### A send job
565
+
566
+ Two rules keep a send job as safe as the gem can make it:
490
567
  - **Never let an ambiguous send be retried**, and make sure nothing after it raises.
491
568
  - **Other failed sends may be retried.** The gem treats them as not processed: refused
492
569
  connections, 429s, 4xx responses and per-message rejections. For 4xx and rejections that is
@@ -502,12 +579,12 @@ class SendSmsJob < ApplicationJob
502
579
 
503
580
  def perform(notification_id)
504
581
  notification = Notification.find(notification_id)
505
- CLICKSEND.sms.deliver(to: notification.phone, body: notification.text, custom_string: "notification:#{notification_id}")
582
+ CLICKSEND_JOBS.sms.deliver(to: notification.phone, body: notification.text, custom_string: "notification:#{notification_id}")
506
583
  rescue Clicksend::AmbiguousRequestError
507
- # The message may have gone out. Hand over to a reconciliation step, and make sure nothing
508
- # here raises: an exception would make the job runner retry the send.
584
+ # The message may have gone out. Hand over to reconciliation, and make sure nothing here
585
+ # raises: an exception would make the job runner retry the send.
509
586
  begin
510
- ReconcileSmsJob.perform_later(notification_id) # e.g. checks sms.history for the reference
587
+ ReconcileSmsJob.perform_later(notification_id) # e.g. checks sms.search_history for the reference
511
588
  rescue => e
512
589
  Rails.logger.error("SMS for notification #{notification_id}: outcome unknown, reconciliation not enqueued (#{e.class})")
513
590
  end
@@ -515,22 +592,179 @@ class SendSmsJob < ApplicationJob
515
592
  end
516
593
  ```
517
594
 
518
- Ambiguous errors are rescued inside `perform`, so `retry_on` only sees errors the gem treats as
519
- not processed. When `retry_on` gives up, Rails re-raises the error and your queue backend may
520
- retry the job again.
595
+ `CLICKSEND_JOBS` is a client with a shorter timeout, explained under
596
+ [the timeout budget](#the-timeout-budget). Ambiguous errors are rescued inside `perform`, so
597
+ `retry_on` only sees errors the gem treats as not processed.
598
+
599
+ **Declaration order matters.** `retry_on` and `discard_on` declare `rescue_from` handlers, which
600
+ Rails tries from the bottom up, a job's own before those it inherits. For a send that timed out
601
+ after ClickSend had accepted it:
602
+
603
+ | The job declares | Messages sent |
604
+ |---|---|
605
+ | only `retry_on Clicksend::Error` | 2 |
606
+ | the recipe above | 1 |
607
+ | `retry_on Clicksend::Error`, then `discard_on Clicksend::AmbiguousRequestError` | 1 |
608
+ | the same two lines in the opposite order | **2** |
609
+ | `discard_on Clicksend::AmbiguousRequestError`, under a `retry_on StandardError` inherited from `ApplicationJob` | 1 |
610
+
611
+ `discard_on` accepts the `AmbiguousRequestError` module, so it works when it is declared last.
612
+ But moving one line breaks it silently, and a discarded job reconciles nothing unless you give
613
+ `discard_on` a block. Rescuing inside `perform` doesn't depend on order.
614
+
615
+ **Retry layers stack.** When `retry_on` runs out of attempts it re-raises the error, and the queue
616
+ backend then retries the job under its own policy (Sidekiq: 25 retries by default). Attempts
617
+ multiply: with a persistent 429, `retry_on Clicksend::Error, attempts: 2` made 6 HTTP requests
618
+ (2 job runs, each with the gem's 3 attempts) before the error reached the backend. On Sidekiq's
619
+ ActiveJob adapter, a job that did *not* rescue an ambiguous error sent twice under
620
+ `retry_on attempts: 2`, and was then in Sidekiq's retry set, ready to send a third time. Keep
621
+ ambiguous errors inside `perform`, and pass `retry_on` a block if the backend shouldn't retry after
622
+ it: with a block, `retry_on` calls it instead of re-raising.
623
+
624
+ ### The timeout budget
625
+
626
+ When a job runner stops (a deploy, a scale-down), it waits a limited time for running jobs, then
627
+ puts the unfinished ones back on the queue. A send still waiting for ClickSend's answer then runs
628
+ again, and the gem never sees how the first attempt ended. A real Sidekiq 8.1.7 process stopped
629
+ mid-send with `-t 2` re-queued the recipe's job, and the message was received **twice** with the
630
+ gem's default 30s read timeout. With `timeout: 1`, the send timed out first, the recipe handled the
631
+ ambiguous error, and the message was received once.
632
+
633
+ So give jobs a client whose attempts end well inside the runner's shutdown timeout:
634
+
635
+ ```ruby
636
+ # config/initializers/clicksend.rb
637
+ CLICKSEND = Clicksend::Client.new(logger: Rails.logger, instrumenter: ActiveSupport::Notifications)
638
+ # Sidekiq waits 25s by default (-t, :timeout). One attempt here gives up after about 5s without a
639
+ # connection or 15s without data from ClickSend (each read gets 15s, so an answer that trickles in
640
+ # can take longer), and max_retries: 0 leaves not-processed failures to retry_on.
641
+ CLICKSEND_JOBS = CLICKSEND.with(timeout: 15, max_retries: 0)
642
+ ```
643
+
644
+ - The window that matters runs from sending the request to reading the answer. A worker stopped
645
+ while connecting or backing off has sent nothing, so its re-run sends once. Retries inside the
646
+ gem add attempts, each with its own window: keep their total inside the budget too, or turn
647
+ them off as above.
648
+ - Sidekiq's 25 seconds comes from its source (8.1.7), and the duplicate was measured with Sidekiq.
649
+ Other limits were not tested here: Solid Queue's README gives a default `shutdown_timeout` of
650
+ 5 seconds; Kubernetes (`terminationGracePeriodSeconds`) and Heroku limit how long a stopping
651
+ process may run; queues with a visibility timeout redeliver a job that runs longer than it.
652
+ Check yours, and raise it rather than cutting the client's timeout below what ClickSend needs.
653
+ - A shorter timeout turns more slow sends into ambiguous ones: more reconciliation, never a
654
+ duplicate.
521
655
 
522
656
  **Never wrap a send in `Timeout.timeout`.** It interrupts the thread at an arbitrary point, which can
523
657
  be after ClickSend has already received the message, and raises a plain `Timeout::Error`. That isn't
524
658
  a `Clicksend::Error` and isn't marked ambiguous, so neither the gem nor the recipe above can tell
525
659
  that the message may have gone out, and a job runner will retry the job and may send it twice. Use
526
- the client's own timeouts instead (for a job, e.g. `CLICKSEND.with(timeout: 10)`): a read timeout
527
- then raises an ambiguous `Clicksend::TimeoutError` that the recipe handles.
660
+ the client's own timeouts instead: a read timeout then raises an ambiguous `Clicksend::TimeoutError`
661
+ that the recipe handles.
662
+
663
+ ### Marking a send in flight
664
+
665
+ A killed process (SIGKILL, out of memory, a lost host), a recovered job or a double enqueue re-runs
666
+ a job however the timeouts are set, and the recipe can't see the first run. If a duplicate matters,
667
+ record that the send is in flight **before** calling `deliver`, on the row that already represents
668
+ the message in your database, and let a run that finds that mark reconcile instead of sending. The
669
+ gem can't do this for you: only your database knows which jobs started. With a worker that died
670
+ after ClickSend had accepted the message:
671
+
672
+ | Approach | Messages sent |
673
+ |---|---|
674
+ | the recipe alone | 2 |
675
+ | claim and send inside one database transaction | **2**: the dying worker's rollback erased the claim |
676
+ | a conditional `UPDATE`, committed before `deliver` | 1; the re-run found the claim and sent nothing |
677
+ | the same, with 4 runs of one job at once | 1 |
678
+
679
+ The recipe with a marker:
680
+
681
+ ```ruby
682
+ # notifications: sms_state (string, default "pending"), sms_claimed_at, sms_message_id
683
+ class SendSmsJob < ApplicationJob
684
+ self.log_arguments = false
685
+ retry_on Clicksend::Error, attempts: 5, wait: :polynomially_longer # sees only failures that were not processed
686
+
687
+ def perform(notification_id)
688
+ # The claim commits on its own: never call this inside a transaction that also covers deliver.
689
+ claimed = Notification.where(id: notification_id, sms_state: "pending")
690
+ .update_all(sms_state: "sending", sms_claimed_at: Time.current) == 1
691
+ return unless claimed # done, failed, or another run is sending (or died sending): never send here
692
+
693
+ notification = Notification.find(notification_id)
694
+ message = CLICKSEND_JOBS.sms.deliver(to: notification.phone, body: notification.text, custom_string: "notification:#{notification_id}")
695
+ notification.update_columns(sms_state: "sent", sms_message_id: message.message_id)
696
+ rescue Clicksend::AmbiguousRequestError
697
+ Notification.where(id: notification_id).update_all(sms_state: "unknown") # reconciled later, never resent here
698
+ rescue Clicksend::MessageRejected
699
+ Notification.where(id: notification_id).update_all(sms_state: "failed")
700
+ rescue Clicksend::Error
701
+ Notification.where(id: notification_id, sms_state: "sending").update_all(sms_state: "pending") # not processed
702
+ raise # release the claim, and let retry_on try again
703
+ end
704
+ end
705
+ ```
706
+
707
+ Anything that fails after the claim, including the updates in the `rescue` clauses, leaves the row
708
+ `sending`, and a re-run then sends nothing: the safe direction. A 429 that outlasted the gem's
709
+ retries released the claim, and the job's retry sent the message once.
710
+
711
+ ### Reconciling with history
712
+
713
+ With the marker, reconciliation needs no hand-over: rows left `sending` or `unknown` are the
714
+ work list. Check them with [`search_history`](#when-a-sends-outcome-is-unknown) from a recurring
715
+ job, not straight after the failure: ClickSend doesn't say how soon a sent message appears in
716
+ history.
717
+
718
+ ```ruby
719
+ # Run every few minutes (e.g. a Solid Queue recurring task or a cron job).
720
+ class SmsReconciliationJob < ApplicationJob
721
+ def perform
722
+ Notification.where(sms_state: %w[sending unknown]).where("sms_claimed_at < ?", 10.minutes.ago).find_each do |notification|
723
+ found = CLICKSEND.sms.search_history(to: notification.phone, custom_string: "notification:#{notification.id}",
724
+ sent_after: notification.sms_claimed_at)
725
+ if found.any?
726
+ notification.update_columns(sms_state: "sent", sms_message_id: found.first.message_id)
727
+ elsif notification.sms_claimed_at < 1.day.ago
728
+ notification.update_columns(sms_state: "unresolved") # for a person to decide
729
+ end
730
+ end
731
+ end
732
+ end
733
+ ```
734
+
735
+ Not finding the message is **not** a reason to resend it: an empty result doesn't prove that
736
+ nothing was sent. Keep checking for a while, then let a person or the user decide (for a login
737
+ code, the user asking for a new one is usually safer). Each check reads at least one page of
738
+ history, against ClickSend's undocumented rate limits.
739
+
740
+ ### Sidekiq without ActiveJob
741
+
742
+ The recipe works the same way in a `Sidekiq::Job`. Sidekiq can also stop an ambiguous error that
743
+ escapes `perform` from being retried:
744
+
745
+ ```ruby
746
+ class SendSmsWorker
747
+ include Sidekiq::Job
528
748
 
529
- **Job runners are at-least-once.** If a worker is killed or shut down mid-send (Sidekiq's default
530
- shutdown timeout, 25s, is shorter than the gem's 30s read timeout), the job runs again and the
531
- gem never sees the first attempt's outcome. If a duplicate matters, record that a send is in
532
- flight before calling `deliver`, and reconcile instead of sending when a job finds that record
533
- already there. The gem can't do this for you: only your database knows which jobs started.
749
+ # Safety net: an ambiguous error goes to the Dead set instead of being retried.
750
+ sidekiq_retry_in { |_count, error, _job| :kill if error.is_a?(Clicksend::AmbiguousRequestError) }
751
+ sidekiq_retries_exhausted { |job, error| Sidekiq.logger.warn("#{job["class"]} #{job["jid"]} gave up: #{error.class}") }
752
+
753
+ def perform(notification_id)
754
+ # as SendSmsJob#perform above: rescue Clicksend::AmbiguousRequestError and reconcile
755
+ end
756
+ end
757
+ ```
758
+
759
+ In Sidekiq 8.1.7 (measured with a real Sidekiq process, and read in its source):
760
+ - `:kill` puts the job in the Dead set and calls `sidekiq_retries_exhausted` and the death
761
+ handlers. **Pressing "Retry Now" on that dead job runs the send again**: reconcile first.
762
+ - `:discard` drops the job and calls only the death handlers, so nothing is left in the Web UI to
763
+ investigate.
764
+ - `nil` keeps the normal retry schedule.
765
+
766
+ Prefer rescuing inside `perform` and keep `:kill` as the safety net. `sidekiq_retries_exhausted`
767
+ also runs when the ordinary retries run out.
534
768
 
535
769
  ## Calling other ClickSend endpoints
536
770
 
@@ -543,7 +777,7 @@ response.data # the envelope's "data" (frozen Hash/Array)
543
777
  response.response_code # => "SUCCESS"
544
778
  response.http_status; response.headers; response.body
545
779
 
546
- client.request(:put, "/v3/sms/#{message_id}/cancel")
780
+ client.request(:get, "/v3/statistics/sms", operation: "statistics.sms")
547
781
  client.request(:get, "/v3/sms/templates", query: {page: 2})
548
782
 
549
783
  # Any paginated list, as raw Hashes:
@@ -580,7 +814,6 @@ ActiveSupport::Notifications.subscribe("request.clicksend") do |event|
580
814
  event.payload
581
815
  # => {http_method: :post, path: "/v3/sms/send", operation: "sms.deliver", idempotent: false,
582
816
  # attempts: 1, http_status: 200, response_code: "SUCCESS", ambiguous: false}
583
- StatsD.distribution("clicksend.request", event.duration, tags: ["operation:#{event.payload[:operation]}"])
584
817
  end
585
818
  ```
586
819
 
@@ -594,20 +827,87 @@ contain no phone numbers or message text. Paths and `operation:` labels you pass
594
827
  are reported as you wrote them, minus any query string or fragment. (An exception object attached
595
828
  by ActiveSupport carries the response body of an API error, as `#body` does.)
596
829
 
597
- **OpenTelemetry and other generic HTTP instrumentation.** Auto-instrumentation of Faraday or
598
- Net::HTTP (for example `opentelemetry-instrumentation-faraday`) works below this gem and records the
599
- full request URL, query string included. `sms.history(to: ...)` sends `q=to:+61...`, so recipients'
600
- phone numbers can end up in your traces. Configure that instrumentation to drop or sanitise URLs and
601
- query strings, or exclude ClickSend's host from it. ClickSend-specific tracing built on this gem's
602
- `request.clicksend` events doesn't have the problem, because those payloads never contain a query
603
- string. A dedicated adapter that does this (`clicksend-opentelemetry`) is planned but not released.
830
+ **Structured logs.** For `key=value` or JSON logs, subscribe to both events and keep the fields you
831
+ want:
832
+
833
+ ```ruby
834
+ ActiveSupport::Notifications.subscribe(/\.clicksend\z/) do |event|
835
+ p = event.payload
836
+ fields = {event: event.name, operation: p[:operation], method: p[:http_method], path: p[:path],
837
+ status: p[:http_status], code: p[:response_code], attempts: p[:attempts], ambiguous: p[:ambiguous],
838
+ attempt: p[:attempt], delay: p[:delay], error: p[:error_class] || p.dig(:exception, 0),
839
+ duration_ms: event.duration.round(1)}.compact
840
+ Rails.logger.info(fields.map { |key, value| "#{key}=#{value}" }.join(" "))
841
+ end
842
+ # event=request.clicksend operation=sms.deliver method=post path=/v3/sms/send status=200 code=SUCCESS attempts=1 ambiguous=false duration_ms=184.2
843
+ # event=request.clicksend operation=sms.deliver method=post path=/v3/sms/send attempts=1 ambiguous=true error=Clicksend::TimeoutError duration_ms=15001.7
844
+ ```
845
+
846
+ **Metrics.** Label by `operation` and an outcome, **never by `path`**: paths such as
847
+ `/v3/sms/receipts/{message_id}` contain IDs, so every message would create new series.
848
+
849
+ ```ruby
850
+ ActiveSupport::Notifications.subscribe("request.clicksend") do |event|
851
+ p = event.payload
852
+ outcome = if p[:ambiguous] then "ambiguous" # may have been processed: reconcile
853
+ elsif p[:exception] then "error"
854
+ else "ok" # MessageRejected is raised after this event, where you call deliver
855
+ end
856
+ tags = {operation: p[:operation] || "other", outcome: outcome}
857
+
858
+ StatsD.distribution("clicksend.request.duration", event.duration, tags: tags) # statsd-instrument, milliseconds
859
+ # prometheus-client: REQUEST_SECONDS.observe(event.duration / 1000.0, labels: tags)
860
+ # Yabeda: Yabeda.clicksend.request_duration.measure(tags, event.duration / 1000.0)
861
+ end
862
+ ```
863
+
864
+ Alert on `outcome=ambiguous` (each one is a send to reconcile) and on `retry.clicksend` events whose
865
+ `error_class` is `Clicksend::RateLimitError`.
866
+
867
+ **Rails 8.1 structured events.** The events plug into `Rails.event` through a
868
+ `StructuredEventSubscriber` (`retry` is a legal method name):
604
869
 
605
- A `Clicksend::Client` is frozen after construction and holds no mutable state. Share one client
606
- across threads, Puma workers and Sidekiq jobs. Loggers and instrumenters are called on the calling
607
- thread and must be thread-safe.
870
+ ```ruby
871
+ # config/initializers/clicksend.rb
872
+ class ClicksendEvents < ActiveSupport::StructuredEventSubscriber
873
+ def request(event)
874
+ emit_event("clicksend.request", event.payload.slice(:operation, :http_status, :response_code, :attempts, :ambiguous)
875
+ .merge(duration_ms: event.duration.round(2)))
876
+ end
877
+
878
+ def retry(event)
879
+ emit_event("clicksend.retry", event.payload.slice(:operation, :attempt, :delay, :error_class, :http_status))
880
+ end
881
+ end
882
+ ClicksendEvents.attach_to :clicksend
883
+ ```
608
884
 
609
- The default Net::HTTP adapter opens a connection per request, which costs a TCP and TLS handshake
610
- each time. For high volumes, the `faraday-net_http_persistent` gem reuses connections:
885
+ **OpenTelemetry.** Generic HTTP instrumentation works below this gem and records the request URL
886
+ with its query string, which this gem keeps out of its own logs, errors and events. For
887
+ `sms.history(to:)` and `sms.search_history`, that is the recipient's phone number. With
888
+ `opentelemetry-instrumentation-faraday` 0.33.0 the span (named just `GET`) recorded
889
+ `url.full=https://rest.clicksend.com/v3/sms/history?order_by=date%3Aasc&q=to%3A%2B61411111111`;
890
+ `opentelemetry-instrumentation-net_http` 0.29.1 records the same query as `url.query`. Both also
891
+ send a `traceparent` header to ClickSend. With those versions:
892
+ - the Net::HTTP instrumentation skips ClickSend with `untraced_hosts: ["rest.clicksend.com"]`;
893
+ - the Faraday instrumentation has no such option. Don't enable it, or wrap ClickSend calls in
894
+ `OpenTelemetry::Common::Utilities.untraced { ... }`, which suppresses every span inside the block.
895
+
896
+ For ClickSend spans without that problem, use
897
+ [`clicksend-opentelemetry`](companions/clicksend-opentelemetry): one span per call, with retries
898
+ as events and ambiguity as an attribute, and never a query string, body or phone number. It is
899
+ available in this repository and released separately from this gem, on its own version line; it
900
+ is not on RubyGems yet.
901
+
902
+ **Thread safety.** A `Clicksend::Client` is frozen after construction and holds no mutable state.
903
+ Share one client across threads, Puma workers and Sidekiq jobs. Loggers and instrumenters are
904
+ called on the calling thread and must be thread-safe.
905
+
906
+ **Persistent connections.** The default Net::HTTP adapter opens a connection per request, which
907
+ costs a TCP and TLS handshake each time. The `faraday-net_http_persistent` gem reuses connections.
908
+ In a local benchmark (a loopback TLS server adding a simulated 25 ms round trip; ClickSend's own
909
+ latency wasn't measured), the median send took 31 ms instead of 84 ms, and 8 threads used one
910
+ connection each instead of one per request.
611
911
 
612
912
  ```ruby
613
913
  threads = ENV.fetch("RAILS_MAX_THREADS", 5).to_i # every thread that shares this client
@@ -615,12 +915,14 @@ CLICKSEND = Clicksend::Client.new(adapter: [:net_http_persistent, {pool_size: th
615
915
  ```
616
916
 
617
917
  The pool must be **at least as large as the number of threads sharing the client** (Puma's
618
- threads, Sidekiq's concurrency). A thread that can't get a connection in time fails with a
619
- timeout, and the gem can't tell that nothing was sent, so a send fails as ambiguous. With this
620
- adapter, a refused connection or connect timeout is also reported as possibly sent. Neither case
621
- sends anything twice, but both can leave a message unsent that the default adapter would have
622
- retried. Build the client once (`with` creates a new pool), and note that this adapter isn't part
623
- of this gem's test suite.
918
+ threads, Sidekiq's concurrency). A thread that can't get a connection in time fails with a timeout:
919
+ in the same local benchmark, 31 of 400 sends from 8 threads sharing `pool_size: 2` timed out
920
+ waiting. Since 1.2 the gem recognises that wait, a refused connection and a connect timeout under
921
+ this adapter as "not sent", so they are retried like any other failure that never reached
922
+ ClickSend, but each still uses up a retry and adds latency. TLS errors and connection resets stay
923
+ "possibly sent", because the adapter can raise them after the request was written. Build the
924
+ client once (`with` creates a new pool). A real-socket spec in this gem's suite checks that the
925
+ adapter never repeats a send by itself.
624
926
 
625
927
  ## Testing your application
626
928
 
@@ -655,6 +957,50 @@ Clicksend::Client.new(username: "test", api_key: "test", transport: fake,
655
957
  retry_policy: Clicksend::RetryPolicy.new(base_delay: 0, max_delay: 0))
656
958
  ```
657
959
 
960
+ **Assert on what was sent.** Opt-in matchers and assertions read `fake.sent_messages` (accepted
961
+ messages; rejected recipients don't count). The gem depends on neither framework: these files load
962
+ only when your test suite requires them. Each key is optional and names a `SentMessage` attribute
963
+ (`to`, `body`, `custom_string`, `from`, `list_id`, `scheduled_at`, `country`, `message_id`,
964
+ `sent_at`). Values are matched with `===`, so strings, regexps and procs all work. **Without a
965
+ count, exactly one message must match**, because a second one is the duplicate you are testing
966
+ for. A failure lists what was sent, one line per message, at most ten messages.
967
+
968
+ ```ruby
969
+ require "clicksend/testing/rspec" # in spec/spec_helper.rb; adds the matchers to every example group
970
+
971
+ RSpec.describe "sign-in codes" do
972
+ let(:fake) { Clicksend::Testing::FakeAPI.new } # a new fake per example, so there is nothing to reset
973
+
974
+ it "texts the code once" do
975
+ fake.client.sms.deliver(to: "+61411111111", body: "Your code is 481516", custom_string: "otp:42") # your code, given fake.client
976
+
977
+ expect(fake).to have_sent_sms(to: "+61411111111", body: /481516/, custom_string: "otp:42")
978
+ expect(fake).to have_sent_sms(custom_string: "otp:42").once # also .twice, .times(n), .exactly(n).times
979
+ expect(fake).not_to have_sent_sms(to: "+61422222222") # none matching, however many
980
+ end
981
+
982
+ it "texts nobody until asked" do
983
+ expect(fake).to have_sent_no_sms
984
+ end
985
+ end
986
+ ```
987
+
988
+ ```ruby
989
+ require "clicksend/testing/minitest"
990
+
991
+ class SignInCodeTest < Minitest::Test # or ActiveSupport::TestCase
992
+ include Clicksend::Testing::MinitestAssertions
993
+
994
+ def test_texts_the_code_once
995
+ fake = Clicksend::Testing::FakeAPI.new
996
+ fake.client.sms.deliver(to: "+61411111111", body: "Your code is 481516", custom_string: "otp:42") # your code, given fake.client
997
+
998
+ assert_sms_sent fake, to: "+61411111111", body: /481516/, custom_string: "otp:42" # count: 2 for two
999
+ assert_no_sms_sent fake, to: "+61422222222"
1000
+ end
1001
+ end
1002
+ ```
1003
+
658
1004
  **Simulate failures, including the ambiguous ones.** For outcomes where it matters you must say
659
1005
  whether ClickSend processed the request before the failure, which is exactly the question your
660
1006
  code has to cope with:
@@ -668,15 +1014,47 @@ fake.fail_next(:connection_refused) # never sent: the gem retries it tra
668
1014
  fake.fail_next(status: 429, retry_after: 0) # rate limited: retried
669
1015
  fake.fail_next(status: 401)
670
1016
  fake.fail_next(:timeout, processed: true, path: "/v3/sms/send", times: 2) # only matching requests
1017
+ fake.fail_next(:interrupted, processed: false) # not ClickSend: your job runner stopping the worker (below)
1018
+ ```
1019
+
1020
+ **Simulate the worker being stopped mid-send.** Job runners re-run a job that was stopped while
1021
+ running. Sidekiq's shutdown pushes busy jobs back to the queue and raises into their threads, and a
1022
+ deploy's SIGTERM or a killed process does much the same. If that happens during a send, the re-run
1023
+ sends again unless your job recorded "sending" before the call. `fail_next(:interrupted, ...)` lets
1024
+ you test that. It models your job runner, not anything ClickSend does: the fake raises
1025
+ `Clicksend::Testing::SimulatedInterrupt`, either after ClickSend accepted the message
1026
+ (`processed: true`) or before it saw it (`processed: false`). It is an `Exception` but not a
1027
+ `StandardError`, so the client lets it through untouched and never retries it, and a
1028
+ `rescue => e` doesn't catch it. It is not an `Interrupt` either, so an unrescued one fails just
1029
+ that test.
1030
+
1031
+ ```ruby
1032
+ fake.fail_next(:interrupted, processed: true, path: "/v3/sms/send") # stopped after ClickSend accepted it
1033
+ expect { SendOtpJob.perform_now(otp.id) }.to raise_error(Clicksend::Testing::SimulatedInterrupt) # the runner requeues it
1034
+ SendOtpJob.perform_now(otp.id) # the re-run finds your "sending" marker
1035
+ expect(fake).to have_sent_sms(custom_string: "otp:#{otp.id}") # exactly one, not two
671
1036
  ```
672
1037
 
673
1038
  The fake also serves receipts, replies (marked read as ClickSend documents; whether the cutoff is
674
1039
  inclusive is the fake's guess) and the account. Exceptions raised by your stub blocks surface as
675
1040
  `Clicksend::Testing::StubError`, never as a simulated ClickSend failure.
676
1041
 
677
- It deliberately **doesn't serve history**: ClickSend doesn't say how soon a sent message appears
678
- there, and an always-current fake history would let a "not in history, so resend" rule pass its
679
- tests and send twice in production. Stub `GET /v3/sms/history` with the rows each test needs.
1042
+ It deliberately **doesn't serve history by itself**: ClickSend doesn't say how soon a sent message
1043
+ appears there, and an always-current fake history would let a "not in history, so resend" rule
1044
+ pass its tests and send twice in production. Say what history shows at each point of your test:
1045
+
1046
+ ```ruby
1047
+ fake.stub_history # nothing (yet)
1048
+ fake.stub_history(fake.sent_messages.last) # this message, status "Sent"
1049
+ fake.stub_history(fake.sent_messages.last, status: "Cancelled")
1050
+ ```
1051
+
1052
+ Every history request then gets exactly those rows, whatever its filters.
1053
+
1054
+ `client.sms.cancel` works on a message the fake accepted with a schedule still in the future, and
1055
+ the message then appears in `fake.cancelled_messages` (it stays in `sent_messages`). ClickSend
1056
+ doesn't document what it answers for any other message, so the fake raises `StubError` instead of
1057
+ guessing; `fake.stub(:put, "/v3/sms/#{id}/cancel") { ... }` states the answer your test assumes.
680
1058
 
681
1059
  The fake simplifies, so don't let your tests depend on these:
682
1060
  - Recipients that aren't 6 to 15 digits (optionally after `+`) get `INVALID_RECIPIENT`; ClickSend's
@@ -711,7 +1089,8 @@ per-message status `COUNTRY_NOT_ENABLED`, which `deliver` raises as `MessageReje
711
1089
  | Send SMS (single, batch, lists, scheduled) | `POST /v3/sms/send` | `sms.deliver`, `sms.deliver_batch` |
712
1090
  | Delivery receipts | `GET /v3/sms/receipts[/{id}]`, `PUT /v3/sms/receipts-read` | `sms.receipts`, `sms.receipt`, `sms.mark_receipts_read` |
713
1091
  | Replies (inbound SMS) | `GET /v3/sms/inbound`, `PUT /v3/sms/inbound-read[/{id}]` | `sms.inbound`, `sms.mark_inbound_read`, `sms.mark_inbound_message_read` |
714
- | Message history | `GET /v3/sms/history` | `sms.history` |
1092
+ | Cancel a scheduled SMS | `PUT /v3/sms/{message_id}/cancel` | `sms.cancel` |
1093
+ | Message history | `GET /v3/sms/history` | `sms.history`, `sms.search_history` |
715
1094
  | Pushed receipts and replies (webhooks) | automation rules with the URL action | `Clicksend::Webhook` |
716
1095
  | Account balance | `GET /v3/account` | `account.fetch` |
717
1096
  | Testing without the network | | `Clicksend::Testing::FakeAPI` |
@@ -721,6 +1100,10 @@ per-message status `COUNTRY_NOT_ENABLED`, which `deliver` raises as `MessageReje
721
1100
  Deliberately **not** wrapped:
722
1101
  - Fax and post. ClickSend no longer offers them to new customers.
723
1102
  - Email, payments and recharge, and reseller features.
1103
+ - Price quotes (`POST /v3/sms/price`). ClickSend says a quote sends no message, but not that it
1104
+ is free of other effects (it returns a `message_id`); call it through `client.request`, which
1105
+ treats it as not safe to repeat.
1106
+ - Cancelling every scheduled message (`PUT /v3/sms/cancel-all`).
724
1107
 
725
1108
  For broad coverage, use ClickSend's official SDK. Notes on ambiguities in ClickSend's
726
1109
  documentation that affect this gem are in [docs/clicksend-api-notes.md](docs/clicksend-api-notes.md).