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. Version 1.0.0 is a deliberately smaller client: retries designed to avoid duplicate SMS, behaviour checked against ClickSend's published contract and a small, free set of live calls, and a release anyone can rebuild 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 and account balance, plus an escape hatch for every other endpoint. It complements ClickSend's official SDK rather than replacing it, and is not affiliated with ClickSend.
- Where it runs
- Published on RubyGems as
clicksend1.0.0, the stable default version. One runtime dependency, Faraday 2. - How it was checked
- 182 specs including local real-socket tests, 28 contract tests against ClickSend's published OpenAPI files, a small set of approved, free live calls, and a byte-identical rebuild of the published gem. No paid SMS was sent, and no live delivery receipt 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.
- 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: 2026, with 1.0.0.rc1 and then 1.0.0 published on 5 October 2026.
- Stack
- Ruby 3.3+, Faraday 2; RSpec, WebMock, SimpleCov and json_schemer for the contract tests; Standard and bundle-audit; GitHub Actions and RubyGems Trusted Publishing.
- My role
- Co-author of the original 2014 gem. I led the 2026 rewrite, testing and release, using an AI coding assistant extensively for implementation and verification.
- More
- The repository · the gem · the 1.0.0 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. Version 1.0 is a focused messaging client that does a few things carefully:
- A narrow scope: single and batch SMS, delivery receipts, replies and account 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.
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. It raises TimeoutError with request_may_have_been_sent? true, and the README shows how to check by your own reference 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.
My own review caught two mistakes before release, 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.
Security hardening
- No credentials in output. They never appear in
inspect, logs or error messages, and logs leave out query strings and bodies. - 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 gem is checked separately.
- Local. 182 RSpec examples with stubs, fixtures and the real-socket tests above. Coverage of
lib/is 98.97% line and 97.51% branch, reported but not enforced as a threshold. CI runs on Ruby 3.3, 3.4 and 4.0 with Standard, bundle-audit and a strict gem build, and any Ruby warning fromlib/fails the suite. - Published contract. 28 tests against ClickSend's OpenAPI files, run on pull requests and weekly. They found four places where ClickSend's own examples contradict its schemas, and pin each one.
- Live, small, approved and free. A test-number run sent only to ClickSend's documented test number and an invalid number. It confirmed an accepted send (status
SUCCESS, a price of zero, an unchanged balance),INVALID_RECIPIENTandCOUNTRY_NOT_ENABLEDreported inside HTTP 200, and the 401 error envelope. A separate unauthenticated run, with no credentials and no messages, produced a real 429 withRetry-After.
The receipt limitation. No live delivery receipt was observed: after the accepted test send, the receipt endpoint returned 404 at about 15, 30, 60 and 120 seconds. Receipt retrieval and parsing are verified only against ClickSend's published examples and the contract tests, and the project's documentation says exactly that.
| 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. |
| Delivery receipts | Not observed live. Checked against published examples and contract tests only. |
| Retry behaviour | Tested locally, against a real socket, for the failure modes above. A design policy, not a guarantee against every possible duplicate. |
| 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
A prerelease, 1.0.0.rc1, went through the same path first and was verified before 1.0.0. The release
itself was a release pull request with green CI and contract checks, a merge commit, and an annotated
v1.0.0 tag, the only tag pushed. Publishing runs in a GitHub Actions workflow that is started
by hand, in an environment that accepts v* tags only, through RubyGems Trusted Publishing
(OIDC), so no long-lived RubyGems API key exists anywhere.
After publishing, the release artifact was checked on its own:
- A clean
gem install clicksendon Ruby 3.3, 3.4 and 4.0 installed 1.0.0. - The published package matched the local build file by file, and its metadata matched.
- Rebuilding the tagged commit with the RubyGems version CI used produced a byte-identical
.gem: SHA-256a36976941399c88a91c14a1c84547595b65a50fbed635e0cb0e0f9ee10ce5c4e.
Outcome
clicksend1.0.0 is the stable, default version on RubyGems, with a GitHub release and green CI.- The library is deliberately small: about 1,280 lines in 17 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, a migration guide for the namespace change, the retry rules, and API notes recording what was verified, what is ambiguous in ClickSend's docs, and what remains unverified.
- It stays maintainable: Dependabot and the weekly contract job watch for drift.
How it was built
I co-wrote the original gem in 2014 and led the 2026 rewrite, testing and release. I used an AI coding assistant extensively for implementation 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. Saying which is which is part of the work.
- Make releases provable. OIDC publishing and a byte-identical rebuild turn "I published it" into "you can check it".
Links
- Source: github.com/prayantr/clicksend (MIT)
- Gem: rubygems.org/gems/clicksend
- Release notes: clicksend 1.0.0
- 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 6 October 2026.
Facing a build like this?