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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +161 -0
- data/README.md +457 -74
- data/docs/clicksend-api-notes.md +49 -5
- data/lib/clicksend/client.rb +16 -0
- data/lib/clicksend/connection.rb +81 -26
- data/lib/clicksend/errors.rb +14 -10
- data/lib/clicksend/instrumentation.rb +6 -0
- data/lib/clicksend/page.rb +6 -1
- data/lib/clicksend/resources/sms.rb +77 -0
- data/lib/clicksend/retry_policy.rb +5 -2
- data/lib/clicksend/testing/failure.rb +15 -4
- data/lib/clicksend/testing/fake_api.rb +87 -18
- data/lib/clicksend/testing/minitest.rb +56 -0
- data/lib/clicksend/testing/payloads.rb +15 -0
- data/lib/clicksend/testing/records.rb +17 -1
- data/lib/clicksend/testing/rspec.rb +124 -0
- data/lib/clicksend/testing/sms_expectations.rb +91 -0
- data/lib/clicksend/testing.rb +6 -0
- data/lib/clicksend/transport.rb +29 -9
- data/lib/clicksend/version.rb +1 -1
- data/lib/clicksend/webhook.rb +22 -16
- metadata +4 -1
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,
|
|
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:#{
|
|
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,
|
|
235
|
-
`custom_string`:
|
|
265
|
+
To check, search [history](#message-history) for your recipient and `custom_string`:
|
|
236
266
|
|
|
237
267
|
```ruby
|
|
238
|
-
|
|
239
|
-
.
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
312
|
-
> for this gem yet
|
|
313
|
-
>
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
|
489
|
-
|
|
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
|
-
|
|
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
|
|
508
|
-
#
|
|
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.
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
|
527
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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(:
|
|
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
|
-
**
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
610
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
|
678
|
-
there, and an always-current fake history would let a "not in history, so resend" rule
|
|
679
|
-
tests and send twice in production.
|
|
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
|
-
|
|
|
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).
|