# 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.

Source: https://voxgig.com/howto/decide-which-sdk-language-to-add-next

- Audience: api-producer
- Level: beginner
- Verified: 2026-10-21
- Published: 2026-10-18

## 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

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A demand ranking from registry downloads, tickets, and access logs](https://github.com/npm/registry/blob/main/docs/download-counts.md) | More integrators call the API than you could ask one by one, and your access log keeps the User-Agent | A 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 state | You 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 description](https://spec.openapis.org/oas/latest.html) | Few customers, many languages, and a description good enough for generators and agents to work from | Every integrator writes the client, the retries, and the auth, and some of them stop before the first call | Customers measure you against vendors who ship a client in their language |
| [Ecosystem parity across every supported target](https://openapi-generator.tech/docs/generators/) | A generator that already emits the language, and a team that can test and support each output | A release, a CI leg, a runtime matrix, and an issue queue per language even when nobody asked for it | Demand 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](https://github.com/npm/registry/blob/main/docs/download-counts.md) returns
tarball downloads for a period, computed once a day.[^1]
[PyPI Stats](https://pypistats.org/api/) returns `last_month` with known mirrors removed, and
[Packagist](https://packagist.org/apidoc) returns `monthly`, `daily`, and an all-time total
together.[^2] The [RubyGems API](https://guides.rubygems.org/rubygems-org-api/) returns one
all-time total per gem. Go has no download count at all: the
[Imported By tab](https://pkg.go.dev/github.com/stripe/stripe-go/v82?tab=importedby) 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.

```js title="registry.mjs"
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.

```bash
node registry.mjs
```

```text output
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.

```js title="rank.mjs"
/** 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 }))
}
```

```bash
node rank.mjs
```

```text output
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`](https://github.com/golang/go/blob/master/src/net/http/request.go),[^4] or
`Go-http-client/2.0` over HTTP/2, and requests sends `python-requests/<version>` from
[`utils.py`](https://github.com/psf/requests/blob/main/src/requests/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.

```js title="agents.mjs"
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'
}
```

```bash
node rank.mjs --logs
```

```text output
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](https://endoflife.date/go),
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.

```bash
node --test rank.test.mjs
```

```text output
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.

```bash
node pitfall.mjs
```

```text output
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.

## Related how-tos

- [Evaluate a third-tier SDK target before you ship it](/howto/evaluate-a-third-tier-sdk-target)

- [Set support tiers for your SDK languages](/howto/set-support-tiers-for-sdk-languages)

- [Decide between generating and hand-writing a client](/howto/generate-or-hand-write-a-small-api-client)

- [Choose developer experience metrics for your API](/howto/choose-developer-experience-metrics-for-your-api)

## 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.

[^1]: npm's [documentation](https://github.com/npm/registry/blob/main/docs/download-counts.md)
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.

[^2]: [PyPI Stats](https://pypistats.org/api/) 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](https://packagist.org/apidoc) 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.

[^3]: The [Imported By tab](https://pkg.go.dev/github.com/stripe/stripe-go/v82?tab=importedby)
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](https://pkg.go.dev/about) says its data arrives from the module index every few
minutes and its source from the module mirror, and mentions no download count anywhere.

[^4]: The constant in [`request.go`](https://github.com/golang/go/blob/master/src/net/http/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.