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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +121 -0
- data/README.md +418 -81
- data/docs/clicksend-api-notes.md +52 -16
- data/lib/clicksend/client.rb +58 -17
- data/lib/clicksend/connection.rb +203 -32
- data/lib/clicksend/errors.rb +99 -3
- data/lib/clicksend/instrumentation.rb +42 -0
- data/lib/clicksend/model.rb +9 -0
- data/lib/clicksend/page.rb +6 -3
- data/lib/clicksend/rate_limit.rb +40 -0
- data/lib/clicksend/resources/account.rb +1 -1
- data/lib/clicksend/resources/sms.rb +98 -20
- data/lib/clicksend/response.rb +35 -0
- data/lib/clicksend/retry_policy.rb +44 -27
- data/lib/clicksend/sms/history_record.rb +85 -0
- data/lib/clicksend/sms/message.rb +1 -1
- data/lib/clicksend/testing/failure.rb +81 -0
- data/lib/clicksend/testing/fake_api.rb +422 -0
- data/lib/clicksend/testing/payloads.rb +88 -0
- data/lib/clicksend/testing/records.rb +27 -0
- data/lib/clicksend/testing.rb +88 -0
- data/lib/clicksend/version.rb +1 -1
- data/lib/clicksend/webhook.rb +182 -0
- data/lib/clicksend.rb +4 -0
- metadata +10 -1
data/README.md
CHANGED
|
@@ -1,37 +1,58 @@
|
|
|
1
1
|
# clicksend
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
3
|
+
[](https://github.com/prayantr/clicksend/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/clicksend)
|
|
5
|
+
[](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"
|
|
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
|
|
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
|
|
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
|
-
|
|
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`
|
|
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;
|
|
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
|
-
|
|
|
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
|
|
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", #
|
|
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.
|
|
181
|
-
needs rules with the **POLL** action for SMS receipts and inbound SMS. You can set these
|
|
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:
|
|
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:
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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"}]},
|
|
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/
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
**
|
|
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
|
|
354
|
-
see the [full list](https://developers.clicksend.com/docs/testing).
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
**
|
|
360
|
-
|
|
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
|
|