clicksend 1.0.0 → 1.1.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
@@ -1,37 +1,58 @@
1
1
  # clicksend
2
2
 
3
- A focused, idiomatic Ruby client for ClickSend messaging: sending SMS (single and batch),
4
- delivery receipts, replies and account balance, over ClickSend's REST v3 API.
5
-
6
- It is **not** a replacement for ClickSend's official, full-API SDK and doesn't try to be.
7
- Every other ClickSend endpoint can still be reached through the same client with
8
- [`client.request`](#calling-other-clicksend-endpoints).
9
-
10
- > **Unofficial.** Community-maintained; not affiliated with or endorsed by ClickSend.
11
- >
12
- > **Status:** `1.0.0`, a rewrite of the 2014 `0.0.x` gem.
13
- > Upgrading? Read [MIGRATING.md](MIGRATING.md). The namespace changed from `ClickSend` to **`Clicksend`**.
3
+ [![CI](https://github.com/prayantr/clicksend/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/prayantr/clicksend/actions/workflows/ci.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/clicksend)](https://rubygems.org/gems/clicksend)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
6
+
7
+ A focused, idiomatic Ruby client for production messaging workloads on ClickSend's REST v3 API:
8
+ single and batch SMS, delivery receipts and replies (polled or pushed), message history and
9
+ account balance.
10
+
11
+ It is **not** another complete ClickSend SDK. ClickSend's official SDK wins on breadth. This gem
12
+ concentrates on the messaging core and on what running it in production needs:
13
+ - Timeouts are on by default, and errors are typed and say which request failed.
14
+ - A message ClickSend refuses inside an HTTP 200 response is reported as an error, not
15
+ silently treated as sent.
16
+ - Retries are designed to avoid sending a duplicate SMS. A missed retry is recoverable; a
17
+ duplicate SMS is not. When a send's outcome is unknown, the error says so
18
+ ([`AmbiguousRequestError`](#when-a-sends-outcome-is-unknown)), and history can help you check.
19
+ - Instrumentation for ActiveSupport::Notifications, an in-memory fake ClickSend for your
20
+ tests, and parsers for ClickSend's webhooks.
21
+
22
+ Endpoints it doesn't wrap are one [`client.request`](#calling-other-clicksend-endpoints) away,
23
+ through the same safe request path ([which client should I use?](#which-client-should-i-use)).
14
24
 
15
25
  ```ruby
26
+ require "clicksend"
27
+
16
28
  client = Clicksend::Client.new(username: ENV["CLICKSEND_USERNAME"], api_key: ENV["CLICKSEND_API_KEY"])
17
29
 
18
- message = client.sms.deliver(to: "+61411111111", body: "Your code is 481516", from: "Acme")
30
+ message = client.sms.deliver(to: "+61411111111", body: "Your code is 481516")
19
31
  message.message_id # => "1ABC3200-C38C-6308-BE4B-C7C51D01DCF0"
20
32
  ```
21
33
 
34
+ > **Stable (1.x)**. Unofficial: not affiliated with ClickSend.
35
+ >
36
+ > Upgrading from the 2014 `0.0.x` gem? Read [MIGRATING.md](MIGRATING.md). The namespace changed
37
+ > from `ClickSend` to **`Clicksend`**.
38
+
22
39
  ## Contents
23
40
 
24
41
  - [Which client should I use?](#which-client-should-i-use)
25
42
  - [Installation](#installation)
26
43
  - [Configuration](#configuration)
27
44
  - [Sending SMS](#sending-sms)
45
+ - [When a send's outcome is unknown](#when-a-sends-outcome-is-unknown)
28
46
  - [Delivery receipts and replies](#delivery-receipts-and-replies)
47
+ - [Webhooks](#webhooks)
48
+ - [Message history](#message-history)
29
49
  - [Account balance](#account-balance)
30
50
  - [Pagination](#pagination)
31
51
  - [Errors](#errors)
32
- - [Timeouts and retries](#timeouts-and-retries)
52
+ - [Timeouts, retries and rate limits](#timeouts-retries-and-rate-limits)
53
+ - [Background jobs](#background-jobs)
33
54
  - [Calling other ClickSend endpoints](#calling-other-clicksend-endpoints)
34
- - [Logging and thread safety](#logging-and-thread-safety)
55
+ - [Logging, instrumentation and thread safety](#logging-instrumentation-and-thread-safety)
35
56
  - [Testing your application](#testing-your-application)
36
57
  - [What is covered](#what-is-covered)
37
58
  - [Using it alongside the official SDK](#using-it-alongside-the-official-sdk)
@@ -42,27 +63,33 @@ message.message_id # => "1ABC3200-C38C-6308-BE4B-C7C51D01DCF0"
42
63
 
43
64
  | You want to… | Use |
44
65
  |---|---|
45
- | Send SMS from a Ruby app and track delivery and replies, with safe defaults | **this gem** |
46
- | Call the occasional ClickSend endpoint this gem doesn't wrap (price a message, cancel a scheduled one, list templates or history) from the same client | **this gem's [`client.request`](#calling-other-clicksend-endpoints)** |
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)** |
47
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) |
48
69
 
49
70
  The two gems can be used side by side ([namespaces differ](#using-it-alongside-the-official-sdk)).
50
71
 
51
- How they differ for the messaging core:
72
+ Comparison with ClickSend's official SDK (`clicksend_client` 6.0.2, September 2026). This is
73
+ a snapshot; the official SDK may have changed since.
52
74
 
53
- | | `clicksend` (this gem) | `clicksend_client` (official, 6.x) |
75
+ | | `clicksend` (this gem) | `clicksend_client` 6.0.2 |
54
76
  |---|---|---|
55
77
  | Scope | SMS, receipts, replies, balance; `client.request` for anything else | Most of the API, generated from OpenAPI |
56
78
  | Timeouts | On by default (30s read, 5s connect) | Off by default (`timeout = 0`) |
57
- | Retries | Built in; never re-sends a message that may already have reached ClickSend | None |
79
+ | Retries | Built in; designed not to re-send a message that may already have reached ClickSend | None |
58
80
  | A message refused inside an HTTP 200 | `deliver` raises `MessageRejected`; `deliver_batch` exposes `#rejected` | Left for you to check |
81
+ | Errors | Typed by status, with the failed request, `retryable?` and `ambiguous?` | One `ApiError` with status, headers and body |
59
82
  | Pagination | `auto_paging_each` | Manual `page`/`limit` |
60
- | Configuration | Immutable client instances | Global `Configuration.default` |
83
+ | Webhooks | `Clicksend::Webhook` parses receipts and replies | Not covered |
84
+ | Testing | `Clicksend::Testing::FakeAPI`, an in-memory ClickSend | Not covered |
85
+ | Instrumentation | ActiveSupport::Notifications events, without personal data | Not covered (debug mode prints credentials and bodies) |
86
+ | Configuration | Immutable client instances | Global `Configuration.default` (per-instance possible) |
61
87
  | Runtime dependencies | Faraday 2 | Typhoeus (libcurl) |
62
88
 
63
89
  ## Installation
64
90
 
65
- Requires Ruby 3.3 or newer.
91
+ Requires Ruby 3.3 or newer. Tested on Ruby 3.3, 3.4 and 4.0; support for a Ruby ends in a minor
92
+ release after its end of life.
66
93
 
67
94
  ```sh
68
95
  gem install clicksend
@@ -96,9 +123,11 @@ A missing credential raises `Clicksend::ConfigurationError` straight away, not o
96
123
  | `timeout` | `30` | Seconds to wait for a response |
97
124
  | `open_timeout` | `5` | Seconds to wait for the connection |
98
125
  | `max_retries` | `2` | Retries for failures that are safe to retry; `0` disables them |
126
+ | `retry_policy` | `RetryPolicy.new` | Backoff timing and retry budget ([details](#timeouts-retries-and-rate-limits)); instead of `max_retries` |
99
127
  | `logger` | `nil` | Any object with `#info`/`#warn`, e.g. `Rails.logger` |
128
+ | `instrumenter` | none | e.g. `ActiveSupport::Notifications` ([events](#logging-instrumentation-and-thread-safety)) |
100
129
  | `base_url` | `https://rest.clicksend.com` | HTTPS only (HTTP is allowed for `localhost`) |
101
- | `adapter` | Net::HTTP | Faraday adapter, e.g. `[:net_http_persistent, {pool_size: 5}]` |
130
+ | `adapter` | Net::HTTP | Faraday adapter; for persistent connections see [thread safety](#logging-instrumentation-and-thread-safety) |
102
131
  | `transport` | Faraday | Replaces the HTTP layer entirely (see [Testing](#testing-your-application)) |
103
132
 
104
133
  Clients are immutable. `with` returns a copy with some settings changed. That is useful for
@@ -124,7 +153,7 @@ CLICKSEND = Clicksend::Client.new(logger: Rails.logger)
124
153
  ```ruby
125
154
  message = client.sms.deliver(
126
155
  to: "+61411111111", # E.164
127
- body: "Your code is 481516", # ClickSend detects Unicode and splits long messages
156
+ body: "Your code is 481516", # long messages are split; Unicode needs the account's "Autodetect" setting
128
157
  from: "Acme", # optional: alpha tag, dedicated number or verified own number
129
158
  schedule: Time.now + 3600, # optional: Time or Unix timestamp
130
159
  custom_string: "otp:user-42" # optional: echoed back in receipts and replies
@@ -173,16 +202,57 @@ batch.total_price # => "0.2376"
173
202
  ```
174
203
 
175
204
  `deliver_batch` never raises when only some messages fail; check `#rejected`.
176
- Each message needs `body` and exactly one of `to` or `list_id`.
205
+ Each message needs `body` and exactly one of `to` or `list_id`. ClickSend doesn't document that
206
+ results come back in request order, so match them to your own records by `custom_string`
207
+ (or `to`), not by position.
208
+
209
+ ## When a send's outcome is unknown
210
+
211
+ ClickSend's send endpoint accepts no idempotency key. If the connection drops or times out after
212
+ the request was written, or ClickSend answers with a 5xx, the message may or may not have been
213
+ accepted. The gem never retries such a send. It raises the error extended with
214
+ `Clicksend::AmbiguousRequestError`:
215
+
216
+ ```ruby
217
+ begin
218
+ client.sms.deliver(to: user.phone, body: text, custom_string: "otp:#{attempt.id}")
219
+ rescue Clicksend::AmbiguousRequestError => e
220
+ e.class # => Clicksend::TimeoutError (or ServerError, ConnectionError, MalformedResponseError,
221
+ # or any APIError ClickSend reported inside a 2xx answer)
222
+ e.request # => #<Clicksend::RequestInfo POST /v3/sms/send operation="sms.deliver" idempotent=false attempts=1>
223
+ e.retryable? # => false
224
+ # Decide before sending again: check history (below), or let the user ask for a new code.
225
+ end
226
+ ```
227
+
228
+ It is still the error class it was, so `rescue Clicksend::TimeoutError` keeps working.
229
+ Failures the gem treats as not processed (a refused connection, a 429, a 4xx response, or a
230
+ message ClickSend refused) are not ambiguous. Apart from the refused connection, that is an
231
+ inference from ClickSend's documentation and observed behaviour, not a guarantee. An error ClickSend reports inside a 2xx
232
+ answer is ambiguous whatever its code, because that behaviour is undocumented.
233
+
234
+ To check, look the message up in [history](#message-history) by recipient and match your own
235
+ `custom_string`:
236
+
237
+ ```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}" }
240
+ ```
241
+
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.
177
245
 
178
246
  ## Delivery receipts and replies
179
247
 
180
- ClickSend can push receipts and replies to a webhook, or you can poll for them. Polling
181
- needs rules with the **POLL** action for SMS receipts and inbound SMS. You can set these up
182
- in the dashboard or through the
183
- [automations API](https://developers.clicksend.com/docs/automations/sms).
248
+ ClickSend can push receipts and replies to a [webhook](#webhooks), or you can poll for them.
249
+ Polling needs rules with the **POLL** action for SMS receipts and inbound SMS. You can set these
250
+ up in the dashboard or through the
251
+ [automations API](https://developers.clicksend.com/docs/automations/sms). ClickSend's test
252
+ numbers produced no receipt in our live check, and ClickSend's legacy v2 docs say none are generated.
184
253
 
185
254
  ```ruby
255
+ started_at = Time.now
186
256
  client.sms.receipts.auto_paging_each do |receipt|
187
257
  receipt.message_id # matches Message#message_id
188
258
  receipt.custom_string
@@ -190,24 +260,103 @@ client.sms.receipts.auto_paging_each do |receipt|
190
260
  receipt.failed? # status_code 301 (see receipt.status_text / error_code)
191
261
  receipt.pending? # status_code 200 or 300 (not final yet)
192
262
  end
193
- client.sms.mark_receipts_read(before: Time.now)
263
+ client.sms.mark_receipts_read(before: started_at)
194
264
 
195
265
  client.sms.inbound.auto_paging_each do |reply|
196
266
  reply.from
197
267
  reply.body
198
268
  reply.original_message_id # the message this replies to
199
269
  end
200
- client.sms.mark_inbound_read(before: Time.now) # or every unread reply, with no argument
270
+ client.sms.mark_inbound_read(before: started_at) # or every unread reply, with no argument
201
271
  client.sms.mark_inbound_message_read(reply.message_id) # just one
202
272
  ```
203
273
 
204
274
  Status codes follow ClickSend's
205
275
  [SMS error codes](https://help.clicksend.com/en/articles/42318-sms-error-codes) article.
206
- `client.sms.receipt(message_id)` fetches a single receipt, including receipts already marked read.
276
+ `client.sms.receipt(message_id)` fetches a single receipt, including receipts already marked read
277
+ (as ClickSend documents).
278
+
279
+ > These lists contain only **unread** items, and listing doesn't mark anything read. If you mark
280
+ > items read while paging through them, later pages shift and you will skip some. Process the
281
+ > pages first, then call `mark_*_read(before:)` with the time you started (not "now": a receipt
282
+ > reported after you listed would also be covered by a later cutoff). Without `before:`,
283
+ > ClickSend marks *everything* read, including items that arrived after you listed them, so that
284
+ > form is never retried. There is no way to mark a single receipt read.
285
+
286
+ ## Webhooks
287
+
288
+ ClickSend pushes receipts and replies to your URL through automation rules with the **URL**
289
+ action. `Clicksend::Webhook` turns a push into the same models polling returns:
207
290
 
208
- > These lists contain only **unread** items. If you mark items read while paging through
209
- > them, later pages shift and you will skip some. Process the pages first, then call
210
- > `mark_*_read(before:)` with the time you started.
291
+ ```ruby
292
+ # config/routes.rb: post "clicksend/:secret/receipts", to: "clicksend_webhooks#receipt"
293
+ class ClicksendWebhooksController < ActionController::API
294
+ def receipt
295
+ secret = Rails.application.credentials.clicksend_webhook_secret
296
+ return head(:not_found) unless ActiveSupport::SecurityUtils.secure_compare(params[:secret].to_s, secret)
297
+
298
+ receipt = Clicksend::Webhook.parse_receipt(request.request_parameters) # => Clicksend::SMS::Receipt
299
+ TrackDeliveryJob.perform_later(receipt.message_id, receipt.status_code) # idempotent on both
300
+ head :ok
301
+ rescue Clicksend::Webhook::InvalidPayload
302
+ head :bad_request
303
+ end
304
+ end
305
+ ```
306
+
307
+ `Webhook.parse_inbound(params)` returns a `Clicksend::SMS::InboundMessage`, and `Webhook.parse`
308
+ works out which of the two it was given. Pass the body parameters, not the route ones, so your
309
+ secret isn't kept in `#raw`.
310
+
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`;
324
+ - treat a push as a hint. A receipt can probably be confirmed with `client.sms.receipt(message_id)`
325
+ (not yet verified for an account with only URL rules), at one API call per check. An
326
+ inbound message can't be fetched by its ID through any wrapped or verified endpoint; the closest
327
+ check is `client.sms.history(from: number)`. Be careful acting on unconfirmed replies such as
328
+ "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.
336
+
337
+ Inbound rules post form fields by default, or use a query string (`webhook_type: "get"`) or JSON
338
+ (`"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.
343
+
344
+ ## Message history
345
+
346
+ ```ruby
347
+ client.sms.history(date_from: Time.now - 86_400, to: "+61411111111").auto_paging_each do |record|
348
+ record.direction # "out" (sent) or "in" (received)
349
+ record.status # "Sent", "Completed", "Failed", "Scheduled", ... (history statuses)
350
+ record.status_code # the gateway code receipts use: 201 delivered, 301 failed; may be nil
351
+ record.custom_string
352
+ end
353
+ ```
354
+
355
+ For this endpoint ClickSend documents a single `q=field:value` filter. Its general search
356
+ documentation also describes several comma-separated fields with an `operator`, but not for history,
357
+ and that hasn't been verified here. So `history` takes at most one of `to:`, `from:`, `status:` and
358
+ `message_id:`, plus `date_from:`, `date_to:` and `order:` (`:asc` or `:desc`). `custom_string` isn't
359
+ a documented filter; match it yourself.
211
360
 
212
361
  ## Account balance
213
362
 
@@ -242,11 +391,11 @@ client.sms.receipts.auto_paging_each.first(250) # stops fetching after 250 item
242
391
  Every error is a `Clicksend::Error`:
243
392
 
244
393
  ```
245
- Clicksend::Error
394
+ Clicksend::Error #request #retryable? #ambiguous?
246
395
  ├── ConfigurationError missing credentials, invalid options
247
396
  ├── ConnectionError no response: DNS, refused, TLS, reset #request_may_have_been_sent?
248
397
  │ └── TimeoutError
249
- ├── APIError #http_status #response_code #response_msg #headers #body
398
+ ├── APIError #http_status #response_code #response_msg #headers #body #rate_limit
250
399
  │ ├── ClientError other 4xx
251
400
  │ │ ├── BadRequestError 400
252
401
  │ │ ├── AuthenticationError 401
@@ -255,15 +404,29 @@ Clicksend::Error
255
404
  │ │ └── RateLimitError 429 #retry_after
256
405
  │ └── ServerError 5xx
257
406
  ├── MalformedResponseError not JSON, or missing documented fields
258
- └── MessageRejected deliver: the message was refused #status #result
407
+ ├── MessageRejected deliver: the message was refused #status #result
408
+ └── Webhook::InvalidPayload a push that can't be parsed
409
+
410
+ Clicksend::AmbiguousRequestError (module) extended onto any of the above when the outcome is unknown
259
411
  ```
260
412
 
413
+ - `#request` is a `Clicksend::RequestInfo`: `http_method`, `path` (never the query string),
414
+ `operation` (e.g. `"sms.deliver"`), `idempotent` and `attempts`. The error message ends with
415
+ it: `HTTP 500 (POST /v3/sms/send)`.
416
+ - `#retryable?` is true when repeating the same request later is safe *and* might work: a 429, a
417
+ connection that never reached ClickSend, or a timeout or 5xx on a request that is safe to
418
+ repeat. It is false for every ambiguous error, every other 4xx, and `MessageRejected`. (A
419
+ `THROTTLED` rejection means an identical message just went to the same recipient.)
420
+ - `#ambiguous?` is true when a request that is not safe to repeat may have been processed. See
421
+ [When a send's outcome is unknown](#when-a-sends-outcome-is-unknown).
422
+
261
423
  `response_code` is ClickSend's application code, for example `INVALID_RECIPIENT`,
262
424
  `INSUFFICIENT_CREDIT` or `COUNTRY_NOT_ENABLED`; see the
263
425
  [list](https://developers.clicksend.com/docs/#application-status-codes). Errors keep the
264
- original exception as `#cause`, and their messages never include your credentials.
426
+ original exception as `#cause`, and their messages never include your credentials, query
427
+ strings or bodies.
265
428
 
266
- ## Timeouts and retries
429
+ ## Timeouts, retries and rate limits
267
430
 
268
431
  Timeouts are always on (`timeout: 30`, `open_timeout: 5`). Failed requests are retried
269
432
  up to `max_retries` times, with exponential backoff and jitter, **only when retrying cannot
@@ -271,41 +434,111 @@ send something twice**:
271
434
 
272
435
  | Failure | Retried for |
273
436
  |---|---|
274
- | 429 Too Many Requests (waits for `Retry-After` if it is 30s or less) | every request: ClickSend did not process it |
437
+ | 429 Too Many Requests (waits for `Retry-After` if it is 30s or less) | every request: documented as "cannot be served" |
275
438
  | Connection refused, DNS failure, connect timeout | every request: it never reached ClickSend |
276
- | Read timeout, connection reset, 5xx | idempotent requests only: `GET`s and the gem's mark-read calls |
439
+ | Read timeout, connection reset, TLS error, 5xx | idempotent requests only: `GET`s, mark-read calls with `before:`, and marking one reply read |
440
+ | An error reported inside a 2xx body; any other 4xx | never |
277
441
 
278
- ClickSend doesn't publish its rate limits. In testing on 2026-10-05, `GET /v3/account` allowed
279
- 20 requests per roughly 60 seconds and answered 429 with `Retry-After` values of 20–39 seconds.
280
- A wait longer than 30 seconds isn't attempted; you get the `RateLimitError` and its `#retry_after`
281
- instead.
442
+ A 429 is documented by ClickSend as a request that "cannot be served", so it is treated as not
443
+ processed. That is an inference from the documentation, not a guarantee. When the gem can't tell
444
+ whether a failure happened before or after the request was sent, it assumes after. A missed
445
+ retry is recoverable; a duplicate SMS is not.
282
446
 
283
- Two more cases are never retried. An error that ClickSend reports only inside a 2xx
284
- response body is undocumented behaviour, so nothing is known about whether the request was
285
- processed. And when the gem can't tell whether a failure happened before or after the
286
- request was sent, it assumes after. A missed retry is recoverable; a duplicate SMS is not.
447
+ The rules above are fixed. What you can tune is the timing and the budget:
287
448
 
288
- ClickSend's send endpoint has no idempotency key. So a send that times out is **not**
289
- retried, and you get a `Clicksend::TimeoutError` whose `request_may_have_been_sent?` is
290
- `true`. The message may or may not have gone out. Before sending again, check with your own
291
- `custom_string`:
449
+ ```ruby
450
+ client = Clicksend::Client.new(
451
+ retry_policy: Clicksend::RetryPolicy.new(
452
+ max_retries: 3, # default 2
453
+ base_delay: 0.5, # seconds; the first backoff is 0.25-0.5s, doubling each time
454
+ max_delay: 8.0, # cap on the backoff
455
+ max_retry_after: 10 # wait for a Retry-After of at most 10s (default 30); longer raises RateLimitError
456
+ )
457
+ )
458
+ ```
459
+
460
+ Any object with `max_retries` and `delay(error:, attempt:)` can be a policy. It is only asked
461
+ about failures that are safe to retry, so no policy can make a send repeat.
462
+
463
+ **Rate limits.** ClickSend doesn't publish its rate limits. In testing, `GET /v3/account` allowed
464
+ 20 requests per roughly 60 seconds, sent `x-ratelimit-limit`, `x-ratelimit-remaining` and
465
+ `ratelimit-reset` headers, and answered 429 with `Retry-After` values of 20-39 seconds. Those
466
+ headers are undocumented, but when they are present you can read them (**experimental**:
467
+ `Clicksend::RateLimit` may change in a minor release if ClickSend changes the headers):
292
468
 
293
469
  ```ruby
294
- history = client.paginate("/v3/sms/history", query: {date_from: started_at.to_i})
295
- already_sent = history.auto_paging_each.any? { |m| m["custom_string"] == "otp:user-42" }
470
+ response = client.request(:get, "/v3/account")
471
+ response.rate_limit # => #<data Clicksend::RateLimit limit=20, remaining=19, reset_in=60>, or nil
472
+ response.request # => #<Clicksend::RequestInfo GET /v3/account ... attempts=1>
473
+
474
+ begin
475
+ client.account.fetch
476
+ rescue Clicksend::RateLimitError => e
477
+ e.retry_after # => 39
478
+ e.rate_limit # the same fields, from the 429 response
479
+ end
296
480
  ```
297
481
 
298
482
  These retries happen inside the gem. The default Net::HTTP adapter does no retrying of its
299
483
  own: Faraday sets `max_retries = 0`, and a test pins this. If you pass a different `adapter:`,
300
484
  check whether that library retries requests by itself.
301
485
 
486
+ ## Background jobs
487
+
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:
490
+ - **Never let an ambiguous send be retried**, and make sure nothing after it raises.
491
+ - **Other failed sends may be retried.** The gem treats them as not processed: refused
492
+ connections, 429s, 4xx responses and per-message rejections. For 4xx and rejections that is
493
+ how ClickSend behaves in practice, not something it documents.
494
+
495
+ ```ruby
496
+ class SendSmsJob < ApplicationJob
497
+ self.log_arguments = false # don't put phone numbers or message text in job logs
498
+
499
+ # Rails checks these from the bottom up, so the more specific rule comes last.
500
+ retry_on Clicksend::Error, attempts: 5, wait: :polynomially_longer # treated as not processed
501
+ discard_on Clicksend::MessageRejected # ClickSend refused the message itself
502
+
503
+ def perform(notification_id)
504
+ notification = Notification.find(notification_id)
505
+ CLICKSEND.sms.deliver(to: notification.phone, body: notification.text, custom_string: "notification:#{notification_id}")
506
+ 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.
509
+ begin
510
+ ReconcileSmsJob.perform_later(notification_id) # e.g. checks sms.history for the reference
511
+ rescue => e
512
+ Rails.logger.error("SMS for notification #{notification_id}: outcome unknown, reconciliation not enqueued (#{e.class})")
513
+ end
514
+ end
515
+ end
516
+ ```
517
+
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.
521
+
522
+ **Never wrap a send in `Timeout.timeout`.** It interrupts the thread at an arbitrary point, which can
523
+ be after ClickSend has already received the message, and raises a plain `Timeout::Error`. That isn't
524
+ a `Clicksend::Error` and isn't marked ambiguous, so neither the gem nor the recipe above can tell
525
+ 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.
528
+
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.
534
+
302
535
  ## Calling other ClickSend endpoints
303
536
 
304
537
  This gem wraps a small part of ClickSend's API on purpose. Everything else is available through
305
538
  the same request path: same authentication, timeouts, retry rules, errors and parsing.
306
539
 
307
540
  ```ruby
308
- response = client.request(:post, "/v3/sms/price", body: {messages: [{to: "+61411111111", body: "Hi"}]}, idempotent: true)
541
+ response = client.request(:post, "/v3/sms/price", body: {messages: [{to: "+61411111111", body: "Hi"}]}, operation: "sms.price")
309
542
  response.data # the envelope's "data" (frozen Hash/Array)
310
543
  response.response_code # => "SUCCESS"
311
544
  response.http_status; response.headers; response.body
@@ -314,16 +547,18 @@ client.request(:put, "/v3/sms/#{message_id}/cancel")
314
547
  client.request(:get, "/v3/sms/templates", query: {page: 2})
315
548
 
316
549
  # Any paginated list, as raw Hashes:
317
- client.paginate("/v3/sms/history", query: {date_from: (Time.now - 7 * 86_400).to_i}).auto_paging_each { |sms| ... }
550
+ client.paginate("/v3/sms/templates").auto_paging_each { |template| ... }
318
551
  ```
319
552
 
320
553
  - Write paths exactly as in ClickSend's [API reference](https://developers.clicksend.com/docs/), starting with `/v3/`.
321
554
  Full URLs are rejected, so your credentials can't be sent to another host.
322
555
  - Pass `body:` as a Hash or Array. It is sent as JSON. `query:` values that are `nil` are left out.
556
+ - `operation:` is an optional label for logs and [instrumentation](#logging-instrumentation-and-thread-safety).
323
557
  - Only `GET` requests are treated as idempotent. ClickSend also uses `POST` and `PUT` for actions like sending
324
- and buying credit. Pass `idempotent: true` only for calls that are safe to repeat.
558
+ and buying credit. Pass `idempotent: true` only for calls that are safe to repeat. A non-idempotent call
559
+ whose outcome is unknown raises an error extended with `Clicksend::AmbiguousRequestError`, as a send does.
325
560
 
326
- ## Logging and thread safety
561
+ ## Logging, instrumentation and thread safety
327
562
 
328
563
  With `logger:`, every HTTP attempt logs one line, plus a warning for each retry:
329
564
 
@@ -334,13 +569,123 @@ With `logger:`, every HTTP attempt logs one line, plus a warning for each retry:
334
569
 
335
570
  Lines never contain credentials, query strings, request bodies or response bodies.
336
571
 
337
- A `Clicksend::Client` is frozen after construction and holds no mutable state. The default
338
- Net::HTTP adapter opens a connection per request. Share one client across threads, Puma
339
- workers and Sidekiq jobs.
572
+ With `instrumenter:`, the gem publishes events. Any object with
573
+ `instrument(name, payload) { |payload| ... }` works; that is `ActiveSupport::Notifications`'
574
+ signature, so in Rails:
575
+
576
+ ```ruby
577
+ CLICKSEND = Clicksend::Client.new(logger: Rails.logger, instrumenter: ActiveSupport::Notifications)
578
+
579
+ ActiveSupport::Notifications.subscribe("request.clicksend") do |event|
580
+ event.payload
581
+ # => {http_method: :post, path: "/v3/sms/send", operation: "sms.deliver", idempotent: false,
582
+ # attempts: 1, http_status: 200, response_code: "SUCCESS", ambiguous: false}
583
+ StatsD.distribution("clicksend.request", event.duration, tags: ["operation:#{event.payload[:operation]}"])
584
+ end
585
+ ```
586
+
587
+ | Event | When | Payload |
588
+ |---|---|---|
589
+ | `request.clicksend` | around each call, retries included | `http_method`, `path`, `operation`, `idempotent`; on completion `attempts`, `http_status` (nil without a response), `response_code`, `ambiguous`. ActiveSupport adds `exception` on failure |
590
+ | `retry.clicksend` | before each retry | `http_method`, `path`, `operation`, `attempt` (1 for the first retry), `delay`, `error_class`, `http_status` |
591
+
592
+ Payloads never contain credentials, headers, query strings or bodies, and the wrapped methods' paths
593
+ contain no phone numbers or message text. Paths and `operation:` labels you pass to `client.request`
594
+ are reported as you wrote them, minus any query string or fragment. (An exception object attached
595
+ by ActiveSupport carries the response body of an API error, as `#body` does.)
596
+
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.
604
+
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.
608
+
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:
611
+
612
+ ```ruby
613
+ threads = ENV.fetch("RAILS_MAX_THREADS", 5).to_i # every thread that shares this client
614
+ CLICKSEND = Clicksend::Client.new(adapter: [:net_http_persistent, {pool_size: threads}])
615
+ ```
616
+
617
+ 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.
340
624
 
341
625
  ## Testing your application
342
626
 
343
- **Stub HTTP.** Requests go through Net::HTTP by default, so [WebMock](https://github.com/bblimke/webmock) works:
627
+ **Use the in-memory ClickSend.** `require "clicksend/testing"` (not loaded by default) adds
628
+ `Clicksend::Testing::FakeAPI`. It replaces only the HTTP exchange, so your code runs against a
629
+ real `Clicksend::Client`: argument validation, errors, retry rules and models are the production
630
+ code paths. Nothing is sent and no network is used.
631
+
632
+ ```ruby
633
+ require "clicksend/testing"
634
+
635
+ fake = Clicksend::Testing::FakeAPI.new
636
+ client = fake.client # a real Clicksend::Client using the fake; retries don't wait
637
+
638
+ client.sms.deliver(to: "+61411111111", body: "Your code is 481516", custom_string: "otp:42")
639
+ fake.sent_messages.map { |m| [m.to, m.custom_string] } # => [["+61411111111", "otp:42"]]
640
+ fake.requests.last.path # => "/v3/sms/send" (headers are never kept)
641
+
642
+ fake.reject(to: "+61400000000", status: "INVALID_RECIPIENT") # deliver raises MessageRejected
643
+ fake.add_receipt(for: fake.sent_messages.last, status_code: 201)
644
+ fake.add_inbound(reply_to: fake.sent_messages.last, body: "STOP")
645
+ fake.stub(:get, "/v3/sms/templates") { |request| {"data" => {"data" => []}} } # any other endpoint
646
+ fake.reset!
647
+ ```
648
+
649
+ In your app, inject `fake.client` where you would use your real client. If your code builds its
650
+ own client, give it the fake as its transport, with placeholder credentials and a retry policy
651
+ that doesn't wait:
652
+
653
+ ```ruby
654
+ Clicksend::Client.new(username: "test", api_key: "test", transport: fake,
655
+ retry_policy: Clicksend::RetryPolicy.new(base_delay: 0, max_delay: 0))
656
+ ```
657
+
658
+ **Simulate failures, including the ambiguous ones.** For outcomes where it matters you must say
659
+ whether ClickSend processed the request before the failure, which is exactly the question your
660
+ code has to cope with:
661
+
662
+ ```ruby
663
+ fake.fail_next(:timeout, processed: true) # accepted, response lost: deliver raises AmbiguousRequestError, one message recorded
664
+ fake.fail_next(:timeout, processed: false) # never processed: same error, nothing recorded
665
+ fake.fail_next(:connection_reset, processed: true)
666
+ fake.fail_next(status: 500, processed: false)
667
+ fake.fail_next(:connection_refused) # never sent: the gem retries it transparently
668
+ fake.fail_next(status: 429, retry_after: 0) # rate limited: retried
669
+ fake.fail_next(status: 401)
670
+ fake.fail_next(:timeout, processed: true, path: "/v3/sms/send", times: 2) # only matching requests
671
+ ```
672
+
673
+ The fake also serves receipts, replies (marked read as ClickSend documents; whether the cutoff is
674
+ inclusive is the fake's guess) and the account. Exceptions raised by your stub blocks surface as
675
+ `Clicksend::Testing::StubError`, never as a simulated ClickSend failure.
676
+
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.
680
+
681
+ The fake simplifies, so don't let your tests depend on these:
682
+ - Recipients that aren't 6 to 15 digits (optionally after `+`) get `INVALID_RECIPIENT`; ClickSend's
683
+ own rules decide in reality. It never answers `THROTTLED`, and test numbers always succeed.
684
+ - The balance never changes, and message parts are estimated.
685
+
686
+ It can stand in as a development "dry run" transport too.
687
+
688
+ **Stub HTTP.** Requests go through Net::HTTP by default, so [WebMock](https://github.com/bblimke/webmock) also works:
344
689
 
345
690
  ```ruby
346
691
  stub_request(:post, "https://rest.clicksend.com/v3/sms/send")
@@ -350,25 +695,14 @@ stub_request(:post, "https://rest.clicksend.com/v3/sms/send")
350
695
  }.to_json)
351
696
  ```
352
697
 
353
- **Use ClickSend's test numbers.** These include `+61411111111`, `+14055555555` and `+447777777777`;
354
- see the [full list](https://developers.clicksend.com/docs/testing). Nothing is sent or charged.
355
- A test number only returns `SUCCESS` if its country is enabled for your account. Otherwise
356
- ClickSend answers with the per-message status `COUNTRY_NOT_ENABLED`, which `deliver` raises as
357
- `MessageRejected`.
698
+ **Use ClickSend's test numbers** for checks against the real API. These include `+61411111111`,
699
+ `+14055555555` and `+447777777777`; see the [full list](https://developers.clicksend.com/docs/testing).
700
+ Nothing is sent or charged, and no delivery receipt is generated. A test number only returns
701
+ `SUCCESS` if its country is enabled for your account. Otherwise ClickSend answers with the
702
+ per-message status `COUNTRY_NOT_ENABLED`, which `deliver` raises as `MessageRejected`.
358
703
 
359
- **Replace the transport.** For tests that shouldn't touch HTTP at all, pass any object that
360
- responds to `call(method, path, query:, body:, headers:)` and returns a
361
- `Clicksend::Transport::Response`:
362
-
363
- ```ruby
364
- FakeTransport = Struct.new(:responses) do
365
- def call(method, path, query:, body:, headers:) = responses.shift
366
- end
367
-
368
- client = Clicksend::Client.new(username: "u", api_key: "k", transport: FakeTransport.new([
369
- Clicksend::Transport::Response.new(status: 200, headers: {}, body: '{"data":{"balance":"5.00"}}')
370
- ]))
371
- ```
704
+ **Write your own transport** if you need to: any object that responds to
705
+ `call(method, path, query:, body:, headers:)` and returns a `Clicksend::Transport::Response`.
372
706
 
373
707
  ## What is covered
374
708
 
@@ -377,7 +711,10 @@ client = Clicksend::Client.new(username: "u", api_key: "k", transport: FakeTrans
377
711
  | Send SMS (single, batch, lists, scheduled) | `POST /v3/sms/send` | `sms.deliver`, `sms.deliver_batch` |
378
712
  | Delivery receipts | `GET /v3/sms/receipts[/{id}]`, `PUT /v3/sms/receipts-read` | `sms.receipts`, `sms.receipt`, `sms.mark_receipts_read` |
379
713
  | 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` |
715
+ | Pushed receipts and replies (webhooks) | automation rules with the URL action | `Clicksend::Webhook` |
380
716
  | Account balance | `GET /v3/account` | `account.fetch` |
717
+ | Testing without the network | | `Clicksend::Testing::FakeAPI` |
381
718
  | RCS | sent through `/v3/sms/send` once ClickSend enables it on your account | `sms.deliver` |
382
719
  | Everything else | [API reference](https://developers.clicksend.com/docs/) | `client.request`, `client.paginate` |
383
720