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.

675 RSpec examples, 0 failures (1.2.0)
50 contract tests against ClickSend's OpenAPI files
99.58% line coverage of lib/ (97.84% branch)
1.2.0 on RubyGems, with clicksend-opentelemetry 0.1.0
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, 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 clicksend 1.2.0, with one runtime dependency (Faraday 2), and an optional companion gem, clicksend-opentelemetry 0.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
  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. Reconciling a send whose outcome is unknown, without pretending an empty answer means "not sent".
  4. 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-opentelemetry 0.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 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. 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.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, 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.

One pipeline, two ways in, as designed for 1.0.0. 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 1.0.0 design: the wrapped methods and the escape hatch share one code path. Later releases added methods and hooks to the same pipeline. 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. 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 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. Drawn for 1.0.0; the rule is unchanged since. 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. 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 existing rescue clauses work.
  • Request context. Errors and responses carry the method, path (never the query string), operation and number of attempts.
  • Instrumentation. An instrumenter: hook compatible with ActiveSupport::Notifications reports 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_history looks 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, //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 gems are checked separately.

  1. 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 from lib/ fails the suite.
  2. 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.
  3. 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.
Each layer claims only what it proves, shown with the 1.0.0 counts. 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 layers as they stood at 1.0.0 (the counts have grown since; current figures are above). The receipt gap is part of the diagram, not a footnote. Full diagram.

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.

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.
HistoryChecked live, read-only. The recipient filter and the row shape, on the test-number message.
CancellingExperimental. The 404 path was observed live; a successful cancel was not. The success path is covered by contract tests only.
Delivery receiptsNot observed live. Checked against published examples and contract tests only.
WebhooksExperimental; no real push captured. Parsing is tested against fixtures built from the published payload shapes.
Retry behaviourTested locally, against a real socket, for the failure modes above. A design policy, not a guarantee against every possible duplicate.
Background jobsMeasured locally, with real ActiveJob and Sidekiq processes against stand-ins for ClickSend, not ClickSend itself.
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

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.

From release PR to a verified gem, shown for 1.0.0. 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.
The 1.0.0 release, step by step; 1.1.0, 1.2.0 and the companion followed the same nine steps. Full diagram.

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.

ReleaseSHA-256 of the published .gem
clicksend 1.0.0a36976941399c88a91c14a1c84547595b65a50fbed635e0cb0e0f9ee10ce5c4e
clicksend 1.1.012948d70ff172c439f0c15e60d09af44f89edd28c2718c2d4925b7d378472a18
clicksend 1.2.0f8fa8f43a30b7a4b8e592eca20ea331e3758a6640b3e84f29ef160773c678652
clicksend-opentelemetry 0.1.06593452d5f0eba037fe83d97d020044a027f47cf41185bf8da251e28d8a135de

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

  • clicksend 1.2.0 is the stable, default version on RubyGems, and clicksend-opentelemetry 0.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.

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

Facing a build like this?

← All case studies