Repository navigation
Releases: prayantr/clicksend
Release list
clicksend 1.2.0
Cancelling and reconciling messages, test-framework helpers, opt-in persistent connections, and
hardening. Mostly additive. "Fixed" lists behaviour changes an existing application may notice,
such as Marshal.dump(client) and YAML.dump(client) now raising and stricter Retry-After
parsing. sms.cancel is experimental and may change in a minor release: its successful answer
has not been observed live (see below). The OpenTelemetry instrumenter is a separate gem,
clicksend-opentelemetry (companions/), released on its own schedule.
Added
sms.cancel(message_id)(experimental) cancels one scheduled SMS
(PUT /v3/sms/{message_id}/cancel) and returns nil. ClickSend documents only the successful
answer, and no idempotency, so it is not retried after a timeout or 5xx; such a failure is an
AmbiguousRequestError(the message may or may not have been cancelled). Check
sms.history(message_id:)for the status"Cancelled"when it matters.
PUT /v3/sms/cancel-allis still deliberately not wrapped.
The successful 200SUCCESSanswer is covered by contract specs but was not observed live:
ClickSend's free test number doesn't hold scheduled messages (they show asCompletedwithin
seconds), so it can't exercise a successful cancel. Live cancels of those test-number messages,
a repeated cancel and a random ID all answered HTTP 404NOT_FOUND, raised as
Clicksend::NotFoundError. A 404 doesn't say which case applies; it does not mean "already
sent". The API may change in a minor release once a real cancellation has been observed.sms.search_history(to:, custom_string:, sent_after:, sent_before: nil)returns the outbound
history rows ClickSend shows now for that recipient and exactcustom_string, possibly none. It
widens the date window by five minutes on each side, reads every page (100 rows each), and
requires an E.164 recipient. An empty result is not proof that nothing was sent.- Testing:
FakeAPIcancels messages it holds as scheduled for the future
(fake.cancelled_messages) and raisesStubErrorfor every cancel ClickSend doesn't document,
so tests must stub that answer.fake.stub_history(*messages, status: "Sent")states what
history shows; the fake still serves no history by itself. - Testing: opt-in RSpec matchers,
require "clicksend/testing/rspec":
expect(fake).to have_sent_sms(to:, body:, custom_string:, ...)(anySentMessageattribute,
matched with===) with.once,.twice,.times(n)and.exactly(n).times;
not_to have_sent_sms(...); andhave_sent_no_sms. Without a count exactly one message must
match, so a duplicate fails, and the negated form means "none matching". Failures list what was
sent, one line per message (at most ten, long values shortened). The require includes the
matchers in every example group (Clicksend::Testing::RSpecMatchers). - Testing: opt-in Minitest assertions,
require "clicksend/testing/minitest"and
include Clicksend::Testing::MinitestAssertions:assert_sms_sent(fake, count: 1, **attributes)
(returns the matching messages) andassert_no_sms_sent(fake, **attributes), with the same
matching and failure output. Neither framework is a dependency or loaded by
require "clicksend"orrequire "clicksend/testing". - Testing:
fake.fail_next(:interrupted, processed: true|false)simulates the job runner stopping
the worker mid-send (Sidekiq's shutdown, a deploy's SIGTERM), after or before ClickSend processed
the request. It raisesClicksend::Testing::SimulatedInterrupt, anExceptionthat is neither
aStandardErrornor anInterrupt, so it passes through the client untouched and is never
retried. Use it to test that the job's re-run doesn't send again. ClickSend itself never does
this.
Fixed
- Instrumenters that don't run the block synchronously can no longer send late or return nil.
Arequest.clicksendblock kept by the instrumenter and called after#instrumentreturned
now raisesConfigurationErrorwithout sending (before, the call raisedConfigurationError
and the SMS was sent later anyway). If#instrumentreturns while the block is still running on
another thread, or after swallowing an exception that escaped the request, the call raises a
ConfigurationErrorthat is also anAmbiguousRequestErrorunless the request is idempotent
(before,Client#requestcould return nil while the send went ahead). - A
ScriptErrorfrom code outside the gem is handled like any other failure. A logger or
instrumenter raisingNotImplementedErrororLoadErrorafter a send was accepted escaped as a
non-Clicksend error (also in 1.1); job runners such as Sidekiq rescueExceptionand would run
the job, and the send, again. Loggers, instrumenters, retry policies and custom transports are
now treated the same forStandardErrorandScriptError: the request's own result or error
wins, and a custom transport'sScriptErroris an ambiguousConnectionErrorfor a send.
Interrupt,SystemExitandNoMemoryErrorstill propagate. Clicksend::ClientrefusesMarshal.dumpandYAML.dump(TypeError), including inside
another object such asclient.sms. A client holds the API key, which both used to write out in
clear (e.g. into a cache or a job payload). Build a new client instead. Responses and errors can
still be serialized. Other serializers that walk instance variables (e.g. ActiveSupport's
Object#as_json) are not covered: pass job arguments, not clients.- A retry delay too long to sleep no longer raises
RangeError. With
RetryPolicy.new(max_retry_after: Float::INFINITY), aRetry-After: 99999999999999999999made
Kernel.sleepraiseRangeErrorinstead of theRateLimitError. A delay over 2**31 - 1 seconds
(from any policy) now means "don't retry": the request's own error is raised. - A retry policy answering with an Integer or Rational too large for a Float no longer makes
Ruby print "Integer out of Float range" (with-W); it means "don't retry", as before. - Pagination never raises a non-Clicksend error for a nonsensical page. A
current_pagebelow 1,
or a negativelast_page,totalorper_page, is aMalformedResponseErrorwith the request
attached (before,current_page: -1madenext_pageraiseArgumentError).
client.paginate(path, query: nil)now means no query, likeClient#request; any other
non-Hashquery:raisesArgumentError(before, both raisedNoMethodError). - A response body that isn't valid in its declared charset is classified by its status. A
body labelled e.g.charset=us-ascii,shift_jisorutf-16lethat holds bytes invalid in that
charset made JSON raise anEncodingError, so every such response became an unreadable
(MalformedResponseError) one: a GET's 503 was not retried, and a send's 429 was reported as
ambiguous instead of being retried. Such a body is now treated like any other non-JSON body: an
error status keeps the raw body and its usual error class and retry rule; a 2xx is still a
MalformedResponseError(ambiguous for a send). Bodies labelledutf-8were already handled. RateLimitError#retry_afteraccepts only what RFC 9110 allows: plain non-negative decimal
seconds or an HTTP-date. It used Ruby'sInteger(), so"0x10"meant 16 seconds,"1_0"10 and
"+5"5; those,"-5"(before: 0) and non-String values are now nil, and the retry policy backs
off as for a missing header. It no longer raises fornilheaders or an Array value from a
custom transport, so such a 429 is retried with backoff instead of being raised at once.- Testing:
FakeAPI#client(max_retries:, retry_policy:)silently ignoredmax_retries:. It now
raisesConfigurationError, exactly asClient.newdoes for both, andmax_retries: nilmeans
the default, as inClient.new. - With
adapter: :net_http_persistent, failures before the request was written are no longer
ambiguous. That adapter reports them differently from the default one, so a refused connection
(Net::HTTP::Persistent::Error"connection refused", caused byErrno::ECONNREFUSED), a connect
or TLS-handshake timeout (Net::OpenTimeout, wrapped inFaraday::TimeoutError) and a wait for a
pooled connection longer than connection_pool's 0.5 s (ConnectionPool::TimeoutError) were
classified as possibly sent: a send that never left was anAmbiguousRequestErrorand not
retried. They are now not sent, so they are retried like the default adapter's (and a pool wait is
aTimeoutError). A downed host and TLS errors still count as possibly sent with either adapter.
Changed
- Webhook documentation corrected from new evidence (
Clicksend::Webhookis still
experimental; no behaviour changed). Archived ClickSend help articles and ClickSend's own n8n
and Power Automate integrations list the pushed fields, including legacy duplicates (message,
sms,originalsenderid,messageid,customstring, ...) that stay in#raw. The README and
API notes no longer say that no source ever listed IP addresses: an archived article did, but it
is stale and unpublished, so the gem still offers no allowlist. Archived sources disagree on the
retry schedule, which is now said. Receipts must be deduplicated onmessage_idand
status_code, notmessage_idalone, because a message may get more than one receipt. New
receiver advice: secret rotation,discard_on Clicksend::Webhook::InvalidPayload, Rails'
log filtering, voice/email/fax receipts sharing the format, inbound MMS links, and the
dashboard's "Add Test Reply".
Documentation
- Background jobs rewritten from new measurements (ActiveJob 8.1.4, Sidekiq 8.1.7 and
ActiveRecord 8.1.4 against local stand-ins for ClickSend). The 1.1 recipe stops framework
retries from repeating an ambiguous send, but a real Sidekiq process ...
clicksend 1.1.0
clicksend 1.1.0 adds failure semantics, observability and testing support for production messaging. It is mostly additive; read "Changed" below before upgrading.
gem install clicksend -v 1.1.0Highlights
- Explicit ambiguity:
rescue Clicksend::AmbiguousRequestErrorcatches every failure where ClickSend may or may not have accepted a send. The error keeps its class, so existingrescueclauses still work. - A retry rule no configuration can override. A send that may already have been processed is never repeated automatically.
Clicksend::RetryPolicyonly tunes timing and budget. - Request context on errors and responses, plus
retryable?andambiguous?. sms.history: the closest documented way to look for an ambiguous send. A missing row is not proof that nothing was sent.Clicksend::Testing::FakeAPI: an in-memory ClickSend for tests, with failure injection that asks whether ClickSend processed the request.- Instrumentation for
ActiveSupport::Notifications, without credentials, query strings or bodies. - Experimental: webhook parsing and rate-limit metadata, which rest on behaviour ClickSend doesn't document.
This reduces the risk of duplicate SMS. It is not a guarantee against every possible duplicate; see the README's "Background jobs" section.
Verified: the published gem is byte-identical to a rebuild of v1.1.0 (SHA-256 12948d70ff172c439f0c15e60d09af44f89edd28c2718c2d4925b7d378472a18). It was published with RubyGems Trusted Publishing.
Failure semantics, observability and testing support for production messaging. Mostly additive;
read "Changed" before upgrading. Two additions are experimental and may change in a minor
release: Clicksend::Webhook and Clicksend::RateLimit, because both rest on behaviour ClickSend
doesn't document.
Retry safety is unchanged in principle and stricter in practice. ClickSend has no idempotency key,
so a send that may already have been processed is never repeated automatically, and such failures
are now marked as ambiguous. This reduces the risk of duplicate SMS; it is not a guarantee against
every possible duplicate (for example, a job runner re-running a job after a crash).
Added
-
Explicit ambiguity. When a request that is not safe to repeat (such as an SMS send) fails
in a way that ClickSend may still have processed, the error is extended with
Clicksend::AmbiguousRequestError. That covers:- a timeout or reset after the request may have been written;
- a 5xx;
- an error reported inside a 2xx body;
- a 2xx answer the gem can't read, including a send result without a per-message status.
The error keeps its class, so existing
rescueclauses still work.Error#ambiguous?is the
predicate. (Adupof the error loses the mark;cloneand re-raising keep it.) -
Request context on errors and responses.
Error#requestandResponse#requestreturn a
Clicksend::RequestInfowithhttp_method,path(no query string or fragment),operation
(e.g."sms.deliver"),idempotentandattempts.Response#requestis not aData
member, soResponseequality,to_hand pattern matching are unchanged from 1.0. -
Error#retryable?: whether repeating the same request later is both safe and might succeed. -
Retry configuration.
Clicksend::RetryPolicyis public:
Client.new(retry_policy: RetryPolicy.new(max_retries:, base_delay:, max_delay:, max_retry_after:)).
The rule deciding which failures may be retried lives in the connection and cannot be
changed by any policy, and the connection enforces the policy's ownmax_retries. -
Rate limits (experimental).
Response#rate_limitandAPIError#rate_limitreturn a
Clicksend::RateLimit(limit,remaining,reset_in) from the rate-limit headers observed
live onGET /v3/account. They are nil when ClickSend sends none. -
Instrumentation.
Client.new(instrumenter:)acceptsActiveSupport::Notificationsor any
object with the sameinstrumentsignature that yields once. It publishesrequest.clicksend
andretry.clicksend. Payloads never include credentials, query strings or bodies, and the
wrapped methods' paths contain no phone numbers or message text.Client#requestand
#paginatetake an optionaloperation:label. -
Message history.
sms.history(date_from:, date_to:, to:/from:/status:/message_id:, order:)
returns a page ofClicksend::SMS::HistoryRecord, whosedelivered?,failed?andpending?
follow ClickSend's "SMS error codes" article and are all false when a row can't be classified
(e.g. "Completed" with no gateway code, as observed live). ClickSend documents no way to look up
a send by your own reference; history, filtered by recipient and matched oncustom_string, is
the closest. The README explains why a missing row is not proof that nothing was sent. -
Webhooks (experimental).
Clicksend::Webhook.parse_receipt,.parse_inboundand.parse
turn pushed receipts and replies intoSMS::ReceiptandSMS::InboundMessage. ClickSend
documents no way to authenticate pushes, so there is deliberately no verification method; the
README explains how to secure the endpoint. No real push has been captured yet. -
Testing.
require "clicksend/testing"addsClicksend::Testing::FakeAPI, an in-memory
ClickSend you plug in as the transport, covering sends, receipts, replies and the account. It
records sent messages and can inject failures, including ambiguous ones with an explicit
processed:flag. It deliberately doesn't serve history; stub it. Mistakes in stubs surface
asClicksend::Testing::StubError(not aStandardError), never as a simulated ClickSend
failure.
Changed
Behaviour an existing 1.0 application may notice:
- Mark-read without a cutoff is no longer retried.
sms.mark_receipts_readand
sms.mark_inbound_readwith nobefore:mark everything read at the moment ClickSend
processes the call. Retrying after an unknown outcome could hide items that arrived in between.
Withbefore:they are still retried. - Error messages end with the request they came from, e.g.
HTTP 500 (POST /v3/sms/send).
Code that matched the exact message text needs updating.MessageRejectednow carries
#requesttoo. - Unreadable 2xx answers to sends are ambiguous. Before, they were plain
MalformedResponseErrors. That coversdeliveranddeliver_batch, including a message
without a status, which 1.0 reported as a rejection (MessageRejectedwith a nil status, or
inBatch#rejected). - Custom transports:
- An exception that is not a
Clicksend::Errorbecomes aConnectionErrorthat may have been
sent. It is ambiguous for a send, retried for idempotent requests, and keeps the original as
#cause;rescue MyTransportErroraround client calls no longer matches. - Any other
Clicksend::Errorraised by a transport is treated as an unknown outcome. - A response without a valid HTTP status, or an unexpected 1xx/3xx, is ambiguous for a send
instead of being a rejection. - Exceptions raised by transports are copied before context is added.
- An exception that is not a
- Loggers and instrumenters cannot change a result. A logger or instrumenter that raises
after a request completes is logged and ignored. An instrumenter that never runs the block
raisesConfigurationError, and one that runs it twice can't send twice. - Configuration:
Client.new(max_retries:)andretry_policy:are mutually exclusive.Client#withreplaces
one with the other, andmax_retries: nilnow means the default.RetryPolicy.newvalidates its arguments (ConfigurationError) and returns a frozen policy.Client#request(idempotent:)honours onlytrue; other truthy values, such as1or
"false", no longer make a request retryable.
Compatibility
- Ruby 3.3 or newer, as before; tested on 3.3, 3.4 and 4.0, with a Ruby head canary in CI.
- Faraday 2 remains the only runtime dependency. ActiveSupport is used only in this gem's own
tests;instrumenter:duck-types it. - No public method or class from 1.0 was removed.
require "clicksend/testing"is opt-in and
not loaded byrequire "clicksend".
Verification
- Unit, integration (local real-socket servers) and contract specs cover the new behaviour.
Contract specs check the wrapped operations, the fields the models read, and the documented
page-size range against ClickSend's published OpenAPI files. - Live checks on 2026-10-06 were read-only:
sms.historyfiltered to ClickSend's test number,
and the rate-limit headers onGET /v3/account. No SMS was sent. - Not verified live: a webhook push (none has been captured), and a delivery receipt. The
test number produced none, as ClickSend's legacy docs say it won't. Both are parsed according
to ClickSend's published schemas and archived documentation.
Documentation
- README: positioning, unknown send outcomes and reconciliation, webhooks, history, background
jobs (including at-least-once job runners), instrumentation, and rate limits. Behaviour that
ClickSend doesn't document is labelled as inferred or observed. - API notes: a review of all 34 of ClickSend's OpenAPI sections plus the archived push
documentation; read-only live checks of history and rate-limit headers on 2026-10-06. design/1.1-audit-and-roadmap.md: the audit, the decisions taken, the features rejected, and
the reviews.
clicksend-opentelemetry 0.1.0
The optional OpenTelemetry companion for clicksend, released separately from it (gem "clicksend-opentelemetry"). clicksend itself never requires it.
The first release.
Added
Clicksend::OpenTelemetry::Instrumenter, passed toClicksend::Client.new(instrumenter:). One
span of kind CLIENT per ClickSend API call, retries included, namedclicksend <operation>
(clicksend <METHOD>when there is no operation). Attributes:http.request.method,
server.address,server.port,url.path(never the query string;record_path: falseleaves
it out, and the path out of the exception event's message),http.response.status_code,
error.type, andclicksend.operation,clicksend.idempotent,clicksend.attempts,
clicksend.ambiguous,clicksend.response_code.
Each retry is aclicksend.retryspan event. A failed call sets the span status to ERROR and
adds anexceptionevent whose message is built only from the class, HTTP status, ClickSend's
response_codeand the request line, never from the exception's own message.Clicksend::OpenTelemetry::FanOut, which sends the client's events to several instrumenters
(for exampleActiveSupport::Notificationsand the OpenTelemetry instrumenter) while the
request still runs once.- The instrumenter never changes a call's outcome: its own failures go to
OpenTelemetry.handle_error, the request runs exactly once, and errors, including ambiguous
ones, pass through unchanged. - Requires Ruby 3.3 or newer,
clicksend1.x (from 1.1) andopentelemetry-api1.x. The SDK and
exporters are the application's choice.
SHA-256 of clicksend-opentelemetry-0.1.0.gem on RubyGems: 6593452d5f0eba037fe83d97d020044a027f47cf41185bf8da251e28d8a135de
clicksend 1.0.0
clicksend 1.0.0 is the stable release of the 2014 clicksend gem, rewritten for ClickSend's REST v3 API and modern Ruby. It is a focused, idiomatic SMS client; it is not a replacement for ClickSend's official full-API SDK.
There are no changes to the library's behaviour or public API since 1.0.0.rc1.
gem install clicksendHighlights
- Revival of the 2014 gem. It replaces 0.0.3, which used ClickSend's legacy v2 API.
- Modern Ruby and Faraday. Requires Ruby 3.3+ and Faraday 2, which is the only runtime dependency. Tested on Ruby 3.3, 3.4 and 4.0.
- Focused SMS API:
client.sms.deliverandclient.sms.deliver_batch- delivery receipts and replies
client.account.fetch- lazy pagination
client.request/client.paginatefor any other ClickSend endpoint, through the same authentication, timeouts, retries, errors and parsing
- Typed errors.
ConfigurationError,ConnectionError/TimeoutError,APIErrorand its subclasses,MalformedResponseError, andMessageRejected, which is raised when ClickSend refuses a message inside an HTTP 200. - Retry safety. The gem never re-sends a message that may have reached ClickSend. Requests ClickSend did not process (429s, refused connections, DNS failures, connect timeouts) are retried. Read timeouts, resets, TLS errors and 5xx responses are retried only for idempotent requests.
- Security.
- Clients are immutable and thread-safe, and timeouts are on by default.
- The API key is redacted everywhere.
client.requestcannot send credentials to another host.
Breaking changes from 0.0.x
- The Ruby namespace is now
Clicksend(it wasClickSend), so the gem can be loaded alongside ClickSend's officialclicksend_client6.x. - The legacy v2 client API has been removed.
See MIGRATING.md.
Verification
- Accepted send, verified live. The accepted SMS send path (
POST /v3/sms/send) was verified live against ClickSend's free test number, at no charge. The response was HTTP 200 with statusSUCCESS, an upper-case UUID message ID, an integerdate,message_parts: 0andmessage_price: "0.0000". - Receipts, not live-verified. Receipt retrieval and parsing are verified against ClickSend's published examples and the contract specs. A live receipt was not observed: the test number did not generate one during the two-minute observation window.
Details are in docs/clicksend-api-notes.md.
Publishing
Published to RubyGems from this tag by the Release workflow, using RubyGems Trusted Publishing (OIDC) with no stored API key.
Full changelog: CHANGELOG.md