# Hex.pm Package Rankings & Download Change Monitor (`automa-flow/hex-pm-package-rankings-monitor`) Actor

Monitor Hex.pm package rankings. Track 90-day downloads, rank and version via the official Hex API.

- **URL**: https://apify.com/automa-flow/hex-pm-package-rankings-monitor.md
- **Developed by:** [Vadim Bezrukov](https://apify.com/automa-flow) (community)
- **Categories:** Developer tools, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.60 / 1,000 package results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Hex.pm Package Rankings & Download Change Monitor

See which Elixir/Erlang/Gleam packages sit in the official Hex.pm top-N for
90-day downloads, whether rank, downloads or version moved since your last
successful check, and whether a watched package entered or left that window.
The Actor reads the published Hex HTTP API. It does not scrape HTML package
pages, call `/diff/`, walk the whole catalog, or emit owner emails.

Use it to refresh an Elixir, Erlang or Gleam package dashboard or alert on
changes in packages you follow. The default snapshot returns the current
top-20 packages, one `query_status` and one `run_summary`. Export JSON or CSV,
or pass the Dataset to your automation.

To start collecting history now, choose `monitor` + `changesOnly` on your
first run and keep the same `stateNamespace` on later runs. The first monitor
returns `BASELINE`; the second compares against that saved observation.
Snapshots do not establish a monitor baseline. Hex provides no native ranking
history through these endpoints, so observations begin when you start running
the Actor. Default rank is **90-day `recent_downloads`**; all-time
`total_downloads` is a separate ranking.

By using this Actor you agree to the [Hex Terms of Service](https://hex.pm/policies/termsofservice).
This Actor uses the published Hex HTTP API as a third-party product under those
Terms and collects public package metadata only.

### Choose your workflow

Start with a prepared example:

- [Export the top 20 packages](https://apify.com/automa-flow/hex-pm-package-rankings-monitor/examples/export-hex-pm-top-package-rankings).
- [Monitor rank and download changes](https://apify.com/automa-flow/hex-pm-package-rankings-monitor/examples/monitor-hex-pm-package-rank-changes).
- [Compare a package watchlist](https://apify.com/automa-flow/hex-pm-package-rankings-monitor/examples/watch-named-hex-pm-package-competitors).

Each example opens with its saved input and a preview of the output fields.

| Goal | Settings | What you get |
| --- | --- | --- |
| Export today's official top-N | `snapshot` + `all` | One `package_observation` per package, all `BASELINE` |
| Re-check the same window next week | `monitor` + `changesOnly` | `NEW`, `UPDATED`, `RANK_UP`, `RANK_DOWN`, `EXITED`, `REAPPEARED` |
| Watch named packages | `watchlist` | Observations for those names; rank only if they sit in this topN window |

- **Export top packages** from official `sort=recent_downloads` order, default
  top-20. First run writes `BASELINE` rows and one `query_status`.
- **Monitor rank changes** on the same query later. Keep `stateNamespace`
  stable. Later runs return `NEW`, `UPDATED`, `RANK_UP`, `RANK_DOWN`, `EXITED`
  or `REAPPEARED`.
- **Watch named packages** such as `jason`, `phoenix` and `telemetry`. A name
  missing from this topN window is unranked, not `NOT_FOUND`, while
  `GET /api/packages/{name}` returns 200. A JSON HTTP 404 is item-level
  `NOT_FOUND`.

`BASELINE` identifies an initial observation. In monitor mode it is saved for
the next comparison; in snapshot mode it is an export only. It does not mean
the package was published today.

### Quick start

```json
{
  "mode": "snapshot",
  "rankingMetric": "recent_downloads",
  "topN": 20
}
```

On the 2026-09-13 probe, rank 1 was `jason` and rank 2 was `telemetry`. Then
schedule the same window:

```json
{
  "mode": "monitor",
  "outputMode": "changesOnly",
  "stateNamespace": "top20",
  "rankingMetric": "recent_downloads",
  "topN": 20
}
```

Keep `stateNamespace` stable for a given watch. Snapshot mode does not read or
write history. `snapshot` + `changesOnly` is rejected before any Hex request.

A watchlist accepts `phoenix` or `https://hex.pm/packages/phoenix`. `http://`
URLs and other hosts are `INVALID_INPUT` without a fetch. Rank is the 1-based
index in official list page 1, including holes: a non-object at position 2 does
not make the next package rank 2. A watchlist name outside that window is
unranked (`rank=null`). First-seen unranked names stay `rank=null`. `EXITED` is
used when a prior successful observation of that id had an integer rank and then
left this window for two complete successful runs. `NOT_FOUND` is only used when
the package endpoint returns JSON HTTP 404. A complete, valid ranking plus
verified package responses produces a `SUCCESS` query. A verified `[]` is `NO_RESULTS` on a first
snapshot or first monitor, including with a watchlist; a later monitor with
last-good `active` or `pending_exits` treats `[]` as `PARTIAL` /
`EMPTY_RANKING_LIST` and does not emit `EXITED`.

### Hex API vs this Actor API

- **Hex API:** `GET https://hex.pm/api/packages?sort=recent_downloads&page=1`
  (default) and, for watchlist ids absent from the downloaded first page,
  `GET https://hex.pm/api/packages/{name}`. Public JSON, no token.
  `total_downloads` maps to official `sort=downloads`.
- **Actor API:** Apify input, Dataset rows (`package_observation`,
  `query_status`, `run_summary`), and optional last-good state in this Actor's
  key-value store.

The Actor never requests HTML `/packages/{name}` (Hex ToS §6 scrape ban), never
requests `/diff/` (robots Disallow), and never paginates `page=2+` to recover a
global rank. It never emits `owners` or email.

### What changed, and what did not

Monitor diffs **last successful** state against **current successful** state.

Keep `stateNamespace`, `rankingMetric`, `topN` and the set of watchlist names
unchanged to continue the same comparison. Changing any of these starts a
separate baseline. Reordering the same watchlist does not reset history.

- First successful monitor, including `changesOnly`, emits `BASELINE`.
- A new id after that is `NEW`. An integer rank move is `RANK_UP` / `RANK_DOWN`
  even when 90-day or all-time downloads also moved; `changed_fields` still
  lists every fingerprint field that changed. `UPDATED` is used when rank did
  not move but downloads or `latest_version` did. Day and week download
  counters are observation fields only; they are not in the fingerprint, so
  day/week changes alone do not emit `UPDATED`. Changes to the 90-day or
  all-time counters do: a daily check can charge for updated packages even
  when every rank stays the same.
- An id missing from **two different sequential successful complete** windows
  becomes `EXITED` only if a prior successful observation of that id had an
  integer rank. The first miss is `pending_exits` only. A transport error,
  `OUTPUT_LIMIT`, or mass-removal guard does not advance that counter.
  First-seen unranked watchlist names stay `rank=null` and do not EXIT. A
  package JSON HTTP 404 is `NOT_FOUND`, not `EXITED`.
- `EXITED` means "not in this query's successful top-N / watchlist response".
  It does **not** mean the package was unpublished from Hex.
- If a successful **top-N** window keeps under 50% of the previous ids at the
  same `topN`, the query is `PARTIAL` (mass-removal guard). Named watchlists
  skip that guard. Pending exits are not applied on mass-removal.
- Broken stored state is `STATE_INCOMPATIBLE` for that query, not a silent new
  baseline. The run fails; change `stateNamespace` to start a new baseline.

### Failure semantics

`NO_RESULTS` is a verified list `[]` with no last-good snapshot to protect. It
is never a transport failure, and it is not "this watchlist id is unranked in a
populated list". A later monitor that already has last-good `active` or
`pending_exits` and then sees `[]` is `PARTIAL` / `EMPTY_RANKING_LIST`,
including for named watchlists, so a blank page cannot manufacture `EXITED`.
HTML when JSON was expected, truncated JSON, a list body over 2 MiB
(`SOURCE_LIMIT`), HTTP 5xx, or 429 after retry are `FAILED`. An unparseable
object inside `topN` is `PARTIAL` / `INVALID_LIST` and does not move state.
Unparseable list slots keep distinct `source_id` values (`hexpm:invalid-rank:{n}`)
and `matches_query=false`. A successful list GET followed by failed watchlist
package GETs is `PARTIAL` / `PARTIAL_WATCHLIST` (not a run-level source failure)
and does not move state. Missing packages are JSON HTTP 404; that is item-level
`NOT_FOUND`, not an empty ranking. Invalid input writes one `query_status`
(`INVALID_INPUT`) and a `run_summary` whose status is `INVALID_INPUT` (not a
default snapshot), then fails the run before any Hex request. A resumed run
keeps that failed outcome. If Dataset rows were already written but billing
could not finish, the query is `PARTIAL` / `SPEND_LIMIT`; last-good state is
still saved so the next monitor does not replay those charges.

A non-empty list shorter than `topN` is `PARTIAL` / `INCOMPLETE_LIST`:
there is no verified complete window to compare. Repeated package identities
anywhere on list page 1 are `PARTIAL` / `DUPLICATE_PACKAGE`; the Actor does not
deduplicate or renumber an ambiguous ranking. Both cases emit no package rows,
charge no package or window events, and keep last-good state and pending exits
unchanged. A malformed package detail or a detail identifying a different
package is an item-level `FAILED` under the requested name and makes the query
`PARTIAL`; it cannot update state or confirm an exit.

`maxPackageRows` defaults to 150 so a `topN` of 100 plus EXITED extras can fit.
It never truncates a window: if the package rows do not fit, you get `PARTIAL` /
`OUTPUT_LIMIT` and the previous state is kept.

### Pricing

The configured rates are listed below. The **Pricing** tab is authoritative.

At the default 512 MB and FREE/BRONZE event rates:

| Example | Bill per run |
| --- | ---: |
| Export top 20 | $0.02005 |
| Compare 3 existing named packages | $0.00305 |
| First monitor baseline or an unchanged monitor | $0.01005 |
| Repeat monitor with 20 changed packages | $0.03005 |
| Export top 100 | $0.10005 |

A 30-run month of top-20 monitoring is $0.30150 if every run is unchanged,
or $0.90150 if each run has 20 billable changes. These are calculated examples,
not forecasts; confirmed exits can add change rows. The monitor formula is
**start + $0.01 per completed window + package rate × delivered changes**.
`UPDATED` includes changed 90-day/all-time download counts, not only rank or
version changes. All first-monitor `BASELINE` rows are free package results
in either output mode; the window fee still applies.

On private build `0.1.1` (2026-09-13, MEASURED): snapshot top-20 used one Hex
list GET, ~685 KiB, no proxy, no browser, 2.2 s Actor time, platform cost
about $0.00056. Buyer bill is 20 × $0.001 + start $0.00005 = **$0.02005**.
A quiet second monitor of the same window billed start + `window-check` =
**$0.01005** and charged no `package-result`. These are historical private
validation measurements, not a runtime guarantee.

| Event | When it is charged | FREE / BRONZE |
| --- | --- | ---: |
| `apify-actor-start` | Platform start; one event at the default 512 MB. The Actor never emits it. | $0.00005 |
| `package-result` | Each successful package row in a complete snapshot. In monitor, only `NEW` / `UPDATED` / `RANK_*` / `EXITED` / `REAPPEARED` from a complete check. | $0.001 |
| `window-check` | Monitor mode, fully delivered window, including `NO_RESULTS` and zero-change. Not charged for `PARTIAL` or `FAILED`. | $0.01 |

Verified missing packages (`NOT_FOUND`) and diagnostic rows never incur a
package-result fee. Invalid input and source failures incur no custom events;
the platform start fee still applies once a run starts. If all data was already
delivered and the SDK accepts only part of a charge, the final query becomes
`PARTIAL / SPEND_LIMIT`; accepted charges remain visible in RUN\_SUMMARY.

`package-result` is $0.0008 on SILVER and $0.0006 on GOLD+. Platform usage is
included, not passed through. `apify-default-dataset-item` is off: enabling it
would double-bill every Dataset row.

A run cap must cover start plus the events: $0.02 is not enough for the sample
top-20, and $0.01 is not enough for a quiet monitor. The configured minimum
cap is $0.021 so the Store prefill can finish. This is a minimum **budget**,
not a minimum bill: a three-package snapshot still costs $0.00305. The Actor
default budget is $0.50. Snapshot of 20 packages →
1 × $0.00005 start + 20 × $0.001 = $0.02005. Quiet or first monitor of the
same top-20 → start + $0.01 `window-check` = $0.01005 and $0 change rows.
Retries, `FAILED` and `PARTIAL` are not charged as `package-result` or
`window-check` (the platform still charges start). If Dataset rows were already
written and billing then hits the cap, `window-check` may already have been
charged; that query is `PARTIAL` / `SPEND_LIMIT` and last-good state is saved.

### Limitations

- There is no native Hex history comparable to storing yesterday's JSON
  yourself. `curl` of the same public endpoints remains free.
  Per-version daily download series are outside this Actor's scope.
- The Actor does not cover the whole Hex catalog. It never paginates the
  catalog.
- EXITED tombstones are kept 180 days, at most 20 000 per query. After prune, a
  very old return may classify as `NEW`.
- MVP assumes one writer per `stateNamespace` and sequential runs. There is no
  distributed lock.
- Watchlist package GETs use concurrency 2 and are not exposed in Input.

Legal review on 2026-09-13: `https://hex.pm/robots.txt` disallows only `/diff/`.
`/api/` is allowed. Hex ToS §4 permits API use through a third-party product;
§6 prohibits scraping outside published interfaces. Re-read robots and the
[Hex Terms of Service](https://hex.pm/policies/termsofservice) before Store
publication; a later `Disallow: /api/` is a stop, not something to bypass.

### Use from AI agents with MCP

After Store publication, connect using your own Apify account through
`https://mcp.apify.com?tools=automa-flow/hex-pm-package-rankings-monitor`.
Ask the agent to keep `mode: monitor` and `outputMode: changesOnly` on repeat
runs, and to treat `NO_RESULTS` as a verified empty window, not a source
failure. Execution requires your own authentication and run budget. Public
search discovery becomes verifiable after publication; a private Actor is not
expected in anonymous search. Autonomous agentic payments are not a verified
channel for this Actor.

### Automation example (n8n / Make)

This is a copy-paste illustration, not a hosted integration promise.

1. Save the monitor JSON above as a Task. In Apify Schedules, use
   `0 8 * * *` in UTC for a daily 08:00 check and attach that Task.
2. Add an `ACTOR.RUN.SUCCEEDED` webhook pointing to your n8n/Make webhook URL.
   Read `resource.defaultDatasetId` from the notification and fetch its items
   with your own Apify connection. Inspect `query_status` first: a run can
   finish with a `PARTIAL` business outcome.
3. Keep rows where `record_type=package_observation` and `change_type` is one of
   `NEW`, `UPDATED`, `RANK_UP`, `RANK_DOWN`, `EXITED`, `REAPPEARED`.
4. Open `source_url` for the human check. Ignore `EXITED` as "unpublished from
   Hex".

### Protocol notes

User-Agent: `automa-flow-hex-pm-package-rankings-monitor/0.1`. Origin is fixed
to `https://hex.pm`. Redirects off documented `/api/` paths fail the window.
Timeout 20 s. A list body over 2 MiB is `FAILED` / `SOURCE_LIMIT`. List GET concurrency is 1.
Watchlist GETs use concurrency 2 and are not exposed in Input.

# Actor input Schema

## `mode` (type: `string`):

Get current rankings returns every selected package and writes no history. Monitor changes compares this successful run with the last successful run of the same query in the same state namespace.

## `outputMode` (type: `string`):

All packages returns every current row. Changes only omits UNCHANGED rows. Snapshot plus changesOnly is rejected before any Hex request.

## `stateNamespace` (type: `string`):

Isolates monitor history. Keep this name and the ranking settings unchanged for repeat comparisons. Use letters, numbers, '\_' or '-', for example top20. Run one check at a time per namespace. Snapshot mode saves no history.

## `rankingMetric` (type: `string`):

Choose the official ranking by downloads in the last 90 days or all-time downloads. Changing the metric starts a separate monitor baseline, even with the same state namespace.

## `topN` (type: `integer`):

How many ranked packages to keep from official list page 1 when watchlist is empty. 1-100, default 20. Rank is the 1-based index in that official order, never a local sort of downloads.all. The Actor never requests page 2 or later.

## `watchlist` (type: `array`):

Optional Hex package names or https://hex.pm/packages/{name} URLs, 0-50. Empty means use topN. HTTP URLs and other hosts are INVALID\_INPUT. A name missing from this topN window is unranked (or an EXIT candidate if it had a rank before), not NOT\_FOUND while the package GET returns 200. A JSON HTTP 404 is item-level NOT\_FOUND. Duplicates keep the first name.

## `maxPackageRows` (type: `integer`):

Cap on delivered package\_observation rows. Default 150 so a topN of 100 plus EXITED extras can fit. Status rows are free. If the window does not fit, the run is PARTIAL/OUTPUT\_LIMIT and previous state is kept.

## Actor input object example

```json
{
  "mode": "snapshot",
  "outputMode": "all",
  "stateNamespace": "default",
  "rankingMetric": "recent_downloads",
  "topN": 20,
  "maxPackageRows": 150
}
```

# Actor output Schema

## `results` (type: `string`):

No description

## `runSummary` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "mode": "snapshot",
    "rankingMetric": "recent_downloads",
    "topN": 20
};

// Run the Actor and wait for it to finish
const run = await client.actor("automa-flow/hex-pm-package-rankings-monitor").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "mode": "snapshot",
    "rankingMetric": "recent_downloads",
    "topN": 20,
}

# Run the Actor and wait for it to finish
run = client.actor("automa-flow/hex-pm-package-rankings-monitor").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "mode": "snapshot",
  "rankingMetric": "recent_downloads",
  "topN": 20
}' |
apify call automa-flow/hex-pm-package-rankings-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,automa-flow/hex-pm-package-rankings-monitor"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/eOyrbefAKZHMWgUJf/builds/y0K51UA0FkRYLRFEJ/openapi.json
