Case Study · Open Source
Reviving a 2014 Ruby Gem: When Not to Build an SDK
In 2014 I co-wrote clicksend, a small Ruby gem for ClickSend's SMS API. By 2026 it no longer loaded, ClickSend had a new API and an official SDK, and the useful question was not how to upgrade it but what it should become. The answer is a deliberately smaller client, built around one rule: a missed retry is recoverable, a duplicate SMS is not. Three releases later it can reconcile an uncertain send, has an optional OpenTelemetry companion, and ships through a release pipeline anyone can check, byte for byte.
At a glance
- What it is
- An unofficial, open-source (MIT) Ruby client for ClickSend's REST v3 SMS API: single and batch SMS, delivery receipts, replies, message history and account balance, plus an escape hatch for every other endpoint. Cancelling a scheduled SMS and parsing webhooks are included as experimental features. It complements ClickSend's official SDK rather than replacing it, and is not affiliated with ClickSend.
- Where it runs
- Published on RubyGems as
clicksend1.2.0, with one runtime dependency (Faraday 2), and an optional companion gem,clicksend-opentelemetry0.1.0, that the core never requires. - How it was checked
- 675 specs including real-socket tests, 50 contract tests against ClickSend's published OpenAPI files, small sets of approved, free live calls, adversarial reviews with mutation testing, and byte-identical rebuilds of the 1.0.0, 1.1.0, 1.2.0 and companion gems. No paid SMS was sent. No live delivery receipt, webhook push or successful cancel was observed.
- Hardest problems
-
- Deciding what the gem should be once an official SDK existed.
- Retrying without sending an SMS twice, against an endpoint with no idempotency key.
- Reconciling a send whose outcome is unknown, without pretending an empty answer means "not sent".
- Saying exactly what each test proves, including what could not be verified live.
- Timeline
- The original gem: 2014, released as 0.0.3 in August 2014. The rewrite: 1.0.0.rc1 and 1.0.0 on 5 October 2026; 1.1.0, 1.2.0 and
clicksend-opentelemetry0.1.0 on 6 October 2026. - Stack
- Ruby 3.3+, Faraday 2; RSpec, WebMock, SimpleCov and json_schemer for the contract tests; Standard and bundle-audit; OpenTelemetry API for the companion; GitHub Actions, repository rulesets and RubyGems Trusted Publishing.
- My role
- Co-author of the original 2014 gem. I led the 2026 rewrite, testing and releases, using an AI coding assistant extensively for implementation and verification.
- More
- The repository · the gem · the companion gem · release notes
The problem
The 2014 gem was a thin wrapper around four endpoints of ClickSend's v2 API: about 280 lines, 19 specs and a 0.0.3 release. By 2026 it was a fossil.
- It would not load. On Ruby 3.4 it failed with either major version of Faraday, and all 19 of its specs failed.
- Its tests had rotted. They used a constant Ruby removed in 3.1, and put credentials in stubbed URLs that WebMock no longer matches.
- It had real bugs. Inspecting the client printed the API key, the gem's
sendoverrode Ruby'sObject#send,use_ssl: falsewas silently ignored, and HTTP 401 and 500 responses came back as ordinary hashes. - The ground had moved. ClickSend now had a JSON REST v3 API and an official Ruby SDK generated from its OpenAPI specification.
Bumping dependencies would have been the easy part. Before writing code I read the old implementation,
the v3 API, ClickSend's published OpenAPI files and the official SDK (clicksend_client
6.0.2, as of September 2026). Four findings shaped everything after.
- The official SDK covers the breadth. It is generated and covers most of the API. In the version I reviewed it had no default timeout, no retries and global configuration, and it did not handle failures reported inside successful responses.
- The namespace was taken. The official SDK uses
ClickSend, the namespace the old gem used, so keeping it would break any application that loaded both. - Failures hide inside HTTP 200. ClickSend answers a send with 200 even when it refuses a message, giving the reason per message.
- Sends are not idempotent. There is no idempotency key, so a send that times out may or may not have gone out.
The strategic decision
The question wasn't how to upgrade the old gem. It was what the gem should become now that ClickSend already had an official SDK.
I deliberately did not build a second full SDK. That would have duplicated maintained, generated work and given nobody a reason to choose it. The gem is a focused messaging client that does a few things carefully:
- A narrow scope: sending SMS and the things a sender needs next — receipts, replies, history and balance.
- A new namespace,
Clicksend, so it can be loaded alongside the official SDK. A migration guide covers the break. - An escape hatch,
client.requestandclient.paginate, that reaches any other endpoint through the same authentication, timeouts, retries, errors and parsing, so a new ClickSend endpoint is usable without a release. - Honest positioning: the README says plainly when to use the official SDK instead, and lists what is deliberately not wrapped.
Architecture
One pipeline serves both the wrapped methods and the escape hatch. The client validates its
configuration and is then frozen, so one instance can be shared across threads. A connection layer
parses ClickSend's response envelope, maps failures to typed errors and applies the retry policy. The
transport is the only file that knows about Faraday, holds no credentials, and can be replaced by any
object with one method. Every model keeps ClickSend's full payload in #raw, and
Page fetches further pages lazily, within ClickSend's documented 15 to 100 page size.
A refused message never passes silently. A single sms.deliver raises
MessageRejected when ClickSend refuses the message inside an HTTP 200;
sms.deliver_batch does not raise for a partial failure, and reports it through
#rejected and #all_queued?.
The hard part: retries
A missed retry is recoverable; a duplicate SMS is not.
Most retry logic is written around status codes. For a request that texts a person, that is the wrong question. Each failure is classified by whether the request could have reached ClickSend at all.
| Failure | Retried? |
|---|---|
| Connection refused, DNS failure, connect timeout | Yes, for every method: these happen before anything is written |
| 429 Too Many Requests | Yes, for every method, honouring Retry-After up to 30 seconds. ClickSend documents a 429 as "cannot be served"; that it was not processed is an inference, and the docs say so |
| Read timeout, connection reset, TLS error, 5xx | Only for idempotent requests: reads, and marking items as read |
| The same failures on an SMS send | No. The error says the request may have been sent and, since 1.1, is marked AmbiguousRequestError; the README shows how to check before resending |
| An error reported only inside a 2xx body | Never: nothing is known about whether it was processed |
This is a tested policy, not a guarantee. Local tests run a real TCP server that counts how many times
each request arrives: after a closed connection, a reset, a read timeout, a 503 or a failed TLS handshake,
a send arrives exactly once, while rate-limited requests and refused connections are retried. Ruby's
Net::HTTP quietly retries some requests on its own, so a real-socket test also pins that the
layer underneath does not. max_retries defaults to 2, with exponential backoff and jitter.
Since 1.1 the timing is configurable, but the rule deciding which failures may be retried is not.
My own review caught two mistakes before 1.0, and both are now covered by tests:
- TLS errors are not all "before the request". The first version treated every TLS error as never sent, but the same error class covers failures after the request is written. They are now ambiguous.
- A "successful" response could trigger a retry. An error reported only inside a 2xx body could have caused a send to be repeated. That path is closed.
1.1: making ambiguity visible
The gem refused to repeat an uncertain send, but an application still had to recognise one. 1.1 made the failure semantics explicit and observable, without changing the rule.
- Explicit ambiguity. Any failure ClickSend may still have processed — a timeout after writing, a 5xx, an error inside a 2xx — is marked
AmbiguousRequestError, while keeping its class so existingrescueclauses work. - Request context. Errors and responses carry the method, path (never the query string), operation and number of attempts.
- Instrumentation. An
instrumenter:hook compatible withActiveSupport::Notificationsreports each request and retry. A logger or instrumenter that fails cannot change a request's result. - Message history, with status predicates that follow ClickSend's own gateway codes.
- Testing support. An in-memory fake of the API, so applications can test their sending code — including ambiguous failures — without stubbing HTTP.
- Experimental, and labelled so: webhook parsing and rate-limit headers, because both rest on behaviour ClickSend doesn't document.
Background jobs got the same scrutiny. Experiments with ActiveJob, ActiveRecord and a real Sidekiq 8.1.7 process, run against local stand-ins for ClickSend rather than ClickSend itself, showed that stopping a worker mid-send could re-run the job and send twice with the default 30-second read timeout, and once with a 1-second timeout. The README now gives a timeout budget for job clients, and the recipes stop framework retries from repeating an ambiguous send. It is explicit that this reduces the risk; it does not remove it.
1.2: reconcile, cancel, test
The next question after "this send may have gone out" is "did it?" 1.2 answers what can honestly be answered, and labels the rest.
sms.search_historylooks for a send by recipient and your own reference, reading every page and widening the time window. A match means ClickSend accepted it; an empty result is documented as not proof that nothing was sent, because history can lag and ClickSend doesn't say how much.sms.cancel, experimental. It is never retried after an ambiguous failure, and there is still no wrapper for "cancel all", which would cancel every scheduled message on an account. Its successful answer is covered by contract tests but has not been observed live (see Verification).- Testing helpers: a simulated worker interruption mid-send, and opt-in RSpec matchers and Minitest assertions, so an application can test its own duplicate handling.
- Persistent connections, opt-in. Pooled connections fail in their own ways; the failures that can only happen before a request is written are classified as not sent, and that is pinned with tests on real sockets.
- Webhook replay fixtures built from the published payload shapes, plus a script to capture and redact real pushes. No real push has been captured yet, and the feature stays experimental.
An adversarial review, with mutation testing, went through 1.2 before release and found defects worth
fixing first. The most serious: a logger or instrumenter raising a ScriptError after ClickSend
had accepted a send escaped as a foreign error, which a job runner would retry — a duplicate SMS. The same
flaw existed in 1.1. Another: serialising a client to YAML wrote out its API key. Both are fixed and pinned
by specs that fail without the fix; a client now refuses to be written by Marshal or YAML.
OpenTelemetry, as a separate gem
Tracing belongs to the application, so it is not in the core. clicksend-opentelemetry builds
on the public instrumentation hook alone: one client span per API call, retries as span events, and
ambiguity as an attribute — never a phone number, message body, query string or credential. It depends on
the OpenTelemetry API only; the application chooses the SDK and exporter.
It declares support for clicksend 1.1 and later, and CI holds it to that: its suite runs
against the core in the repository and, separately, against clicksend 1.1.0 installed from
RubyGems, on Ruby 3.3, 3.4 and 4.0. The one spec that needs a 1.2 feature is skipped there with its
reason, and any other skip or failure fails the job.
Security hardening
- No credentials in output. They never appear in
inspect, logs or error messages, and logs leave out query strings and bodies. A client refuses to be serialised by Marshal or YAML, so its key can't reach a cache or a job queue. - The escape hatch cannot send credentials elsewhere. Absolute URLs,
//hostand///hostpaths, backslash hosts, whitespace, control characters and CRLF injection are rejected; redirects are not followed; there is no per-request way to change the host or headers. - HTTPS-only
base_url, with plain HTTP allowed for localhost only, and message IDs validated before they are placed in URL paths. - The echoed API key is redacted. ClickSend's account response includes the subaccount's API key;
Account#rawreplaces it with[REDACTED]. - Private vulnerability reporting is enabled on the repository, and
SECURITY.mdpoints to it.
Verification
Three layers check the code, and each one claims only what it proves. The published gems are checked separately.
- Local. 675 RSpec examples at 1.2.0, with stubs, fixtures and the real-socket tests above. Coverage of
lib/is 99.58% line and 97.84% branch, reported but not enforced as a threshold. CI runs on Ruby 3.3, 3.4 and 4.0 with Standard, dependency audits and strict gem builds, and any Ruby warning fromlib/fails the suite. - Published contract. 50 tests against ClickSend's OpenAPI files, run on pull requests and weekly. They pin every place where ClickSend's own examples contradict its schemas — five response fixtures as of 1.2.
- Live, small, approved and free. Every authenticated call went only to ClickSend's documented free test number (and an invalid number). No paid SMS was ever sent, and the authenticated runs checked that the balance did not change.
The cancel check. For 1.2, one message was scheduled an hour ahead to the free test
number and then cancelled through the gem. ClickSend showed the message as Completed within
seconds — the test number doesn't hold scheduled messages — so the cancel answered HTTP 404
NOT_FOUND, which the gem raised as NotFoundError. A repeated cancel and a random
ID got the same 404, so a 404 doesn't say which case applies; it does not mean "already sent". Seeing a
successful cancel would take a paid scheduled message, and none was sent. That is why
sms.cancel ships as experimental.
| Area | Status, and what it means |
|---|---|
| Sending and refusals | Checked live, free. An accepted send to the documented test number, and refusals reported inside HTTP 200. No paid SMS was sent. |
| Rate limiting | Checked live, separately. The 429 came from an unauthenticated run, not the test-number run. |
| History | Checked live, read-only. The recipient filter and the row shape, on the test-number message. |
| Cancelling | Experimental. The 404 path was observed live; a successful cancel was not. The success path is covered by contract tests only. |
| Delivery receipts | Not observed live. Checked against published examples and contract tests only. |
| Webhooks | Experimental; no real push captured. Parsing is tested against fixtures built from the published payload shapes. |
| Retry behaviour | Tested locally, against a real socket, for the failure modes above. A design policy, not a guarantee against every possible duplicate. |
| Background jobs | Measured locally, with real ActiveJob and Sidekiq processes against stand-ins for ClickSend, not ClickSend itself. |
| Ruby versions | 3.3, 3.4 and 4.0 supported. A Ruby head job runs in CI as a canary that is allowed to fail; head is not supported. |
Release engineering
Every release followed the same path: a release pull request with green CI, a merge commit, an annotated tag on it — the only thing pushed — and a publishing workflow started by hand from that tag. Publishing uses RubyGems Trusted Publishing (OIDC), so no long-lived RubyGems API key exists anywhere. A prerelease, 1.0.0.rc1, went through the path first.
After each release, the published artifact was checked on its own: clean installs from RubyGems on Ruby 3.3, 3.4 and 4.0, the package compared with the local build, and the tagged commit rebuilt to a byte-identical .gem.
| Release | SHA-256 of the published .gem |
|---|---|
clicksend 1.0.0 | a36976941399c88a91c14a1c84547595b65a50fbed635e0cb0e0f9ee10ce5c4e |
clicksend 1.1.0 | 12948d70ff172c439f0c15e60d09af44f89edd28c2718c2d4925b7d378472a18 |
clicksend 1.2.0 | f8fa8f43a30b7a4b8e592eca20ea331e3758a6640b3e84f29ef160773c678652 |
clicksend-opentelemetry 0.1.0 | 6593452d5f0eba037fe83d97d020044a027f47cf41185bf8da251e28d8a135de |
Supply chain and repository protection
A release you can rebuild is only half the story if someone can quietly change what gets built. After 1.2, the repository itself was hardened:
- Separate publishing identities. Each gem has its own Trusted Publishing configuration, workflow and environment, and each environment accepts runs only from its own tag pattern (
v*for the core,clicksend-opentelemetry-v*for the companion), so neither can publish from the other's tags. - Pinned, read-only publishing workflows. Every action in the workflows that hold the publishing credential is pinned to an exact commit, and their token can read the code and request the publishing credential, nothing more.
- Protected
master. Changes arrive only through pull requests that pass 12 required CI checks, with no force pushes, no deletion and no bypass. - Immutable release tags. Release tags can be created but never moved or deleted, by anyone.
- Dependency audits of all three lockfiles CI installs from, and secret scanning with push protection.
Outcome
clicksend1.2.0 is the stable, default version on RubyGems, andclicksend-opentelemetry0.1.0 is published alongside it, both with GitHub releases and green CI.- The library is still small: about 3,400 lines in 29 files under
lib/, with one runtime dependency. - The documentation covers the hard parts: when to use this gem and when to use the official SDK, the retry rules, how to reconcile an ambiguous send, background-job recipes, and API notes recording what was verified, what is ambiguous in ClickSend's docs, and what remains unverified.
- It stays maintainable: Dependabot, the weekly contract job and the oldest-supported-core compatibility job watch for drift.
How it was built
I co-wrote the original gem in 2014 and led the 2026 rewrite, testing and releases. I used an AI coding assistant extensively for implementation, adversarial review and verification. The scope, design and safety decisions, which live checks to run and not run, and the review of the evidence were mine.
The lesson
Sometimes the right way to revive a library isn't to preserve what it was. It's to understand what changed around it, then give it a smaller, more defensible job.
- Read before you rewrite. The audit turned "upgrade the gem" into "build the right, smaller thing", and caught a namespace collision before any code depended on it.
- Design failure handling around what a failure costs, not around status codes.
- Keep verification claims separate. Contract tests show consistency with a spec; live calls show behaviour; "experimental" marks what neither has proven. Saying which is which is part of the work.
- Make releases provable, then protect the proof. OIDC publishing and byte-identical rebuilds turn "I published it" into "you can check it"; pinned workflows and immutable tags keep it that way.
Links
- Source: github.com/prayantr/clicksend (MIT)
- Gems: rubygems.org/gems/clicksend · rubygems.org/gems/clicksend-opentelemetry
- Release notes: clicksend 1.2.0 · clicksend 1.0.0 · all releases
- Related: an SMS-based CRM and survey platform I owned end to end as CTO of TargetMobi
clicksend is an unofficial client and is not affiliated with ClickSend. State as of 7 October 2026.
Facing a build like this?