How-to › Ship an SDK

How to decide which SDK language to add next#

Rank candidate SDK languages from tickets, signup answers, registry counts, and the User-Agents of raw API calls, the source that sees users you have not reached.

Audience
API producer
Level
beginner
Topic
Target another language
Verified

Your SDK ships in TypeScript and Python, and four more languages are on the request list. The loudest request comes from one agency with a shared Slack channel, the registry dashboards count only the two SDKs you have, and the budget covers one more target this year. You need a ranking you can defend.

What you get

You will end up with one ranking of the candidate languages, built from tickets, signup answers, registry snapshots, and the User-Agents in your access log, and a test that pins it. This is for you if you ship an SDK and must pick the next language.

Short answer

Build one ranking from evidence you already hold. Registry downloads measure the SDKs you ship, so they rank nothing you do not ship. Tickets and the signup form count people who asked. The User-Agent in your access log counts people who never asked and call the API with a bare HTTP library, which is the demand a missing SDK hides. Average each candidate’s share of each source, then weigh the winner against the runtime matrix it brings.

You will need

Node 22 or later, and an SDK shipping in at least one language. Also a quarter of support tickets tagged by language, the language answer from your signup form, and an access log that kept the User-Agent header. Verified 2026-10-21 against Node 22.22.0. The sample runs on committed fixtures for a fictional metering API and makes no network call. Its registry readers are tested against responses captured from the five live registries.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A demand ranking from registry downloads, tickets, and access logsMore integrators call the API than you could ask one by one, and your access log keeps the User-AgentA User-Agent classifier you keep current, because every HTTP library sends its own default, and three sources that count different things, so the weights are a judgment you have to stateYou have a handful of customers you can ask, or a gateway rewrites the User-Agent before it reaches your log
An HTTP-only stance with a stronger OpenAPI descriptionFew customers, many languages, and a description good enough for generators and agents to work fromEvery integrator writes the client, the retries, and the auth, and some of them stop before the first callCustomers measure you against vendors who ship a client in their language
Ecosystem parity across every supported targetA generator that already emits the language, and a team that can test and support each outputA release, a CI leg, a runtime matrix, and an issue queue per language even when nobody asked for itDemand sits in two or three languages and the rest would ship untested

Parity ships every target the generator has and pays per language in CI legs, runtime matrices, and triage, even when nobody asked. The HTTP-only stance pays nothing per language and hands the client work to each integrator, some of whom never reach a first call. A demand ranking sits between them, and its cost is the evidence: downloads cover only what you ship, so the ranking needs a source that sees the rest.

Snapshot the registries for the languages you ship

Each registry publishes a count, and each defines it differently. npm’s download counts API returns tarball downloads for a period, computed once a day.1 PyPI Stats returns last_month with known mirrors removed, and Packagist returns monthly, daily, and an all-time total together.2 The RubyGems API returns one all-time total per gem. Go has no download count at all: the Imported By tab on pkg.go.dev counts packages known to its index that import the module.3 registry.mjs holds a request builder and a reader for each.

export const registries = {
  npm: {
    docs: 'https://github.com/npm/registry/blob/main/docs/download-counts.md',
    url: (pkg, { start, end } = {}) => `https://api.npmjs.org/downloads/point/${start && end ? `${start}:${end}` : 'last-month'}/${pkg}`,
    downloads: (body) => body.downloads,
    counts: 'downloads of the tarball in the period asked for',
  },
  pypistats: {
    docs: 'https://pypistats.org/api/',
    url: (pkg) => `https://pypistats.org/api/packages/${pkg}/recent`,
    downloads: (body) => body.data.last_month,
    counts: 'downloads in the last month, known mirrors removed',
  },

snapshot.mjs fetches your own packages through those builders and writes the responses to evidence/registry.json. The page runs on a committed snapshot for the two shipped SDKs.

node registry.mjs
shipped SDKs, 2026-09-01 to 2026-09-30, from evidence/registry.json
  typescript  npm        @meterco/sdk   41,230   https://api.npmjs.org/downloads/point/2026-09-01:2026-09-30/@meterco/sdk
  python      pypistats  meterco        18,904   https://pypistats.org/api/packages/meterco/recent
  candidates with no package to count: go, java, php, ruby

what each registry counts
  npm         downloads of the tarball in the period asked for
  pypistats   downloads in the last month, known mirrors removed
  packagist   downloads in the last month, beside a daily and an all-time figure
  rubygems    downloads since the gem was first pushed, with no period at all
  pkg.go.dev  packages known to the index that import the module, not downloads

Two numbers come back, for the two packages that exist. The four candidates have no package, so this source has nothing to say about them. It answers a neighboring question instead: how much each shipped SDK is used, which decides whether the SDK channel matters enough to extend.

Rank the candidates from the people who asked

Two sources count people who asked. Support tickets carry a language tag and the account that filed them. The signup form asks which language the caller will use, and the answers for a quarter sit in evidence/signups.json. Each source becomes a share: a candidate’s count divided by the total across candidates. The ranking averages the shares with equal weights, and the weights live in one function rather than in the arithmetic.

/** Each language's share of one source; every share is zero when the source is empty. */
export function shares(counts) {
  const total = Object.values(counts).reduce((a, b) => a + b, 0)
  return Object.fromEntries(Object.entries(counts).map(([l, n]) => [l, total ? n / total : 0]))
}

/**
 * Equal-weight average of the candidates' shares of each source, ranked. A share
 * is of the candidates alone, and a candidate a source leaves out counts zero
 * there. Ties fall back to the name.
 */
export function rank(sources, languages) {
  const names = Object.keys(sources)
  const shared = names.map((n) => shares(Object.fromEntries(languages.map((l) => [l, sources[n][l] ?? 0]))))
  return languages
    .map((language) => ({ language, score: shared.reduce((sum, s) => sum + s[language], 0) / names.length }))
    .sort((a, b) => b.score - a.score || a.language.localeCompare(b.language))
    .map((r, i) => ({ ...r, rank: i + 1 }))
}
node rank.mjs
shipped SDKs, downloads 2026-09-01 to 2026-09-30
  typescript  npm        @meterco/sdk   41,230
  python      pypistats  meterco        18,904
  candidates with no package to count: go, java, php, ruby

candidates ranked from tickets, signups, equal weights
  rank  language  tickets (accounts)  signups  score
  1     php            14 (6)              39   0.36
  2     go              9 (8)              44   0.29
  3     java            5 (5)              48   0.25
  4     ruby            4 (3)              12   0.10

PHP leads on tickets and Java leads on signups, and the average puts PHP first. The accounts column is the first warning: fourteen PHP tickets came from six accounts, so one of them has been busy. Both sources count voices, and a voice is a person who found one of the forms.

Add the source that sees people who never asked

Someone calling your API from Java without an SDK files no ticket and may have signed up before the form had a language field. Their requests are in your access log, and the User-Agent header names the library that sent them. An HTTP library sends a default agent unless the caller sets one, and the default names the library. Go’s net/http sends Go-http-client/1.1, a constant in request.go,4 or Go-http-client/2.0 over HTTP/2, and requests sends python-requests/<version> from utils.py. Node’s fetch sends a bare node, with no version, and the aiohttp default puts the Python version first. Your own SDKs send a name of their own, which is what makes the remainder countable as calls with no SDK.

export const RULES = [
  [/^meterco-sdk-ts\//, 'sdk:typescript'],
  [/^meterco-sdk-py\//, 'sdk:python'],
  [/^Go-http-client\//, 'go'],
  [/^(okhttp|Java-http-client|Java|Apache-HttpClient)\//, 'java'],
  [/^(GuzzleHttp\/|Symfony HttpClient )/, 'php'],
  [/^(Faraday |rest-client\/|Ruby$)/, 'ruby'],
  [/^(axios\/|node-fetch\b|undici\b|node$)/, 'typescript'],
  [/^(python-requests|python-httpx|python-urllib3|Python-urllib)\/|^Python\/[\d.]+ aiohttp\//, 'python'],
]

/** The language a User-Agent names, 'sdk:<language>' for your own SDKs, or 'unknown'. */
export function classify(agent) {
  for (const [re, language] of RULES) if (re.test(agent)) return language
  return 'unknown'
}
node rank.mjs --logs
shipped SDKs, downloads 2026-09-01 to 2026-09-30
  typescript  npm        @meterco/sdk   41,230
  python      pypistats  meterco        18,904
  candidates with no package to count: go, java, php, ruby

access log, 815 requests, by User-Agent
  your SDKs                        440   54%
  no SDK, a language you ship       65    8%
  no SDK, a candidate language     280   34%
  not placed                        30    4%   PostmanRuntime/7.42.0, Dart/3.5 (dart:io), curl/8.9.1

candidates ranked from tickets, signups, logs, equal weights
  rank  language  tickets (accounts)  signups  raw calls  score  runtimes in support on 2026-10-18
  1     java            5 (5)              48        140   0.33     6  (27, 25, 21, 17, 11, 8)
  2     go              9 (8)              44         90   0.30     2  (1.27, 1.26)
  3     php            14 (6)              39         35   0.28     4  (8.5, 8.4, 8.3, 8.2)
  4     ruby            4 (3)              12         15   0.09     3  (4.0, 3.4, 3.3)
  moved: java 3 -> 1, php 1 -> 3

A third of the requests came from candidate languages with no SDK, and half of those from Java, which had filed five tickets. Java moves from third to first and PHP from first to third. That is the demand a missing SDK hides: integrators who wrote the client themselves and had no reason to write to you.

The last column is the other side of the scale. It counts the runtime versions each language keeps in support on the snapshot date, read from endoflife.date, because every supported version is a CI leg and a bug report you will receive. Java brings six lines of its eclipse-temurin product and Go brings two. The ranking says what people want; the column says what each answer costs to keep, and the decision weighs one against the other.

Check it worked

Eleven tests cover the registry readers, the classifier, the shares, and the ranking.

node --test rank.test.mjs
1..11
# tests 11
# suites 0
# pass 11
# fail 0
# cancelled 0
# skipped 0
# todo 0

The first test runs each reader over a response captured from the live registry, so the five readers are known to fit the five real shapes. Another asserts that the ranking is php, go, java, ruby from the two sources and java, go, php, ruby once the log joins.

When it goes wrong

The ranking is built from downloads and comes back empty for every candidate. The cause is the source: a registry counts a package, and the candidates have none. Use downloads to size the SDK channel, never to rank what you do not ship.

One account dominates a language. Nine of PHP’s fourteen tickets came from one agency. Counting accounts instead of tickets puts Go ahead of PHP, so print both and decide which one you mean.

The classifier meets an agent it does not know. curl, Postman, and Dart sit in the log, and a guess for any of them would move the ranking. Leave them as not placed and read the list.

node pitfall.mjs
a ranking from downloads alone
  typescript   41,230
  python       18,904
  go          no package to count
  java        no package to count
  php         no package to count
  ruby        no package to count
  downloads rank the two languages you ship and cannot rank the four you do not

tickets by account
  go      9 tickets from 8 accounts, 2 of them from arcus-metering
  java    5 tickets from 5 accounts, 1 of them from fathom-water
  php    14 tickets from 6 accounts, 9 of them from agency-blue
  ruby    4 tickets from 3 accounts, 2 of them from gale-metering
  ranked by tickets:  php, go, java, ruby
  ranked by accounts: go, php, java, ruby

agents the classifier does not place
  curl/8.9.1              15
  PostmanRuntime/7.42.0   10
  Dart/3.5 (dart:io)       5
  30 requests counted as not placed; a guess here would move the ranking without evidence

When not to do this

Do not rank from downloads alone. The registries count the packages you publish, and a language you have never shipped has no number to compare, so a downloads-only ranking is a ranking of the status quo.

Do not read the log as a vote. A single integrator with a busy batch job can send more requests than fifty people with a dashboard. Count distinct API keys beside requests before you trust the raw-call column, as the tickets step counts accounts beside tickets.

Do not add a language because the generator lists it. Parity hands you a runtime matrix per language, and the matrix column in the ranking shows how unequal those are. A Java target carries three times the supported versions of a Go target before the first user arrives.

Do not run this at all with a handful of customers. Ask them. The ranking earns its place when the people you would ask are too many to ask, and the log is the only room they are all in.

Last verified

Verified 2026-10-21 against Node 22.22.0. Every output block is what the command preceding it printed, over committed fixtures for a fictional API. The registry readers were tested against responses captured from the five live registries for Stripe’s packages on 2026-10-18, saved under evidence/live/. The runtime support dates come from the endoflife.date API, saved the same day. No registry was called while the page’s commands ran.

Footnotes

  1. npm’s documentation says the counts come from a map-reduce cluster spun up once a day, after UTC midnight, over the previous day’s logs. So last-day is usually yesterday in GMT, and in California it reaches the current day by late afternoon. A bulk query takes 128 packages at a time, and scoped packages are not supported in bulk, which leaves @meterco/sdk to be asked for on its own. ↩︎ Back to text

  2. PyPI Stats states that all download stats exclude known mirrors and that time series data is retained for 180 days. It adds that nobody should need to fetch the same endpoint more than once a day. Packagist asks every client to send a User-Agent with a mailto= address, so it can contact you when something goes wrong. It also asks for at most ten concurrent requests and no jobs at midnight or on the hour. ↩︎ Back to text

  3. The Imported By tab for one major version of Stripe’s Go module reads “Known importers: 50 (displaying 152 packages, including internal and invalid packages)” in its header. Two numbers share one line, with no definition of either. The site’s about page says its data arrives from the module index every few minutes and its source from the module mirror, and mentions no download count anywhere. ↩︎ Back to text

  4. The constant in request.go carries a comment saying it is not intended to reflect the actual Go version being used. The comment adds that the value was changed at the Go 1.1 release because the former one had been blocked by some intrusion detection systems. The calling card a Go program hands your server over HTTP/1.1 was chosen to get past a doorman. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.