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.

182 RSpec examples, 0 failures
28 contract tests against ClickSend's OpenAPI files
98.97% line coverage of lib/ (97.51% branch)
1.0.0 stable release on RubyGems
Ruby 3.3+ tested on 3.3, 3.4 and 4.0

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 clicksend 1.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
  1. Deciding what the gem should be once an official SDK existed.
  2. Retrying without sending an SMS twice, against an endpoint with no idempotency key.
  3. 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 send overrode Ruby's Object#send, use_ssl: false was 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.

  1. 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.
  2. 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.
  3. Failures hide inside HTTP 200. ClickSend answers a send with 200 even when it refuses a message, giving the reason per message.
  4. 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.request and client.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.

One pipeline, two ways in. Your application calls Clicksend::Client, which validates its configuration and is then frozen. From it, the wrapped methods (client.sms: deliver, deliver_batch, receipts, replies; client.account: balance) and the escape hatch (client.request, client.paginate) both go through one Connection, which parses the envelope, maps typed errors, applies the retry policy and logs without secrets. Then the Transport, which holds no credentials, then Faraday 2, then ClickSend REST v3 over HTTPS.
The wrapped methods and the escape hatch share one code path. Full diagram.

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.

FailureRetried?
Connection refused, DNS failure, connect timeoutYes, for every method: these happen before anything is written
429 Too Many RequestsYes, 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, 5xxOnly for idempotent requests: reads, and marking items as read
The same failures on an SMS sendNo. 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 bodyNever: nothing is known about whether it was processed
When is it safe to retry? Each failure is classified by one question: could the request have reached ClickSend? Definitely not (connection refused, DNS failure, connect timeout): retried for every method. 429: retried if Retry-After is 30 seconds or less. Unknown on a read or mark-read: retried up to max_retries, default 2. Unknown on an SMS send: not retried; raises TimeoutError with request_may_have_been_sent? true. An error only inside a 2xx body: never retried. A design policy with targeted tests, not a guarantee against every possible duplicate.
Retries follow what a repeat could cost, not the status code. Full diagram.

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, //host and ///host paths, 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#raw replaces it with [REDACTED].
  • Private vulnerability reporting is enabled on the repository, and SECURITY.md points to it.

Verification

Three layers check the code, and each one claims only what it proves. The published gem is checked separately.

  1. 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 from lib/ fails the suite.
  2. 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.
  3. 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_RECIPIENT and COUNTRY_NOT_ENABLED reported inside HTTP 200, and the 401 error envelope. A separate unauthenticated run, with no credentials and no messages, produced a real 429 with Retry-After.
Each layer claims only what it proves. 1, local: 182 specs, including a TCP server that counts arrivals; 98.97% line and 97.51% branch coverage. 2, contract: 28 tests against ClickSend's OpenAPI files, which found 4 places where its examples contradict its schemas. 3, live, in two separate runs: a test-number run (accepted send, price 0, balance unchanged; INVALID_RECIPIENT and COUNTRY_NOT_ENABLED inside HTTP 200; the 401 envelope) and a separate unauthenticated run (a real 429 with Retry-After). Live delivery receipt: not observed. Separately, the release artifact: clean installs on Ruby 3.3, 3.4 and 4.0, the package matches the local build, and a byte-identical rebuild.
The receipt gap is part of the diagram, not a footnote. Full diagram.

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.

AreaStatus, and what it means
Sending and refusalsChecked live, free. An accepted send to the documented test number, and refusals reported inside HTTP 200. No paid SMS was sent.
Rate limitingChecked live, separately. The 429 came from an unauthenticated run, not the test-number run.
Delivery receiptsNot observed live. Checked against published examples and contract tests only.
Retry behaviourTested locally, against a real socket, for the failure modes above. A design policy, not a guarantee against every possible duplicate.
Ruby versions3.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.

From release PR to a verified gem. Build and review: 1, release PR #3; 2, CI green on Ruby 3.3, 3.4 and 4.0; 3, merge commit d460f9e; 4, annotated tag v1.0.0, only that tag pushed. Publish and verify: 5, the release workflow, started by hand, v* tags only; 6, Trusted Publishing over OIDC, no stored API key; 7, clicksend 1.0.0 on RubyGems; 8, clean installs on Ruby 3.3, 3.4 and 4.0; 9, a byte-identical rebuild with the same SHA-256.
Nine steps, the last two after publishing. Full diagram.

After publishing, the release artifact was checked on its own:

  • A clean gem install clicksend on 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-256 a36976941399c88a91c14a1c84547595b65a50fbed635e0cb0e0f9ee10ce5c4e.

Outcome

  • clicksend 1.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".

clicksend is an unofficial client and is not affiliated with ClickSend. State as of 6 October 2026.

Facing a build like this?

← All case studies