Skip to content
howmuchusers
Sign in
← Docs

Full reference

Everything there is. Three routes, no SDK — what follows is the exact contract the code already keeps, not a summary of it.

Authentication

The key goes in the header, either of two ways:

Authorization: Bearer hmu_sk_live_9f3ac1d2_...X-HMU-Key: hmu_sk_live_9f3ac1d2_...

The second exists for environments where a proxy already uses "Authorization" for something else.

hmu_pk_live_…hmu_sk_live_…
Where it livesIn the browser. It's public by design.Server only.
Can sendpage.viewed, session.started, your custom eventsEverything the public key can, plus user.created, user.deleted, user.activated, subscription.*
Can importNoYes

The distinction isn't a convention — it's in the code. A user.created sent with the public key is rejected with secret_key_required and logged as a signal for review. It's what keeps a number published on a ranking from being "whatever any visitor wanted it to be".

The secret key is shown once, when it's created. Our database only holds its hash.

POST /v1/events

One event
{ "event": "user.created", "user_id": "usr_123", "created_at": "2026-08-08T20:00:00Z" }
Or up to 100 at once
{ "events": [ { "event": "user.created", "user_id": "usr_123" }, … ] }

Fields

FieldTypeNotes
eventrequiredLowercase, up to three levels: checkout.completed. Can't start with user., subscription. or hmu. unless it's one of ours.
user_idrequired for user eventsA stable, non-personal id. We hash it before storing it, but sending emails anyway is still wrong — we don't need them.
created_atoptionalISO 8601. Defaults to now if missing. 2026-08-08 is accepted too (anchored to noon UTC).
idempotency_keyoptionalIf you retry, the event isn't counted twice.
propsoptionalA flat object, up to 20 keys, simple values. Don't send personal data here.

Response — 202

{ "accepted": 97, "duplicated": 2, "rejected": [ { "index": 4, "code": "missing_user_id", "message": "…" } ] }

A bad event never takes down the batch. The good ones are accepted and you get back the detail of each rejection with its index. If the whole batch failed instead, your retry would fail on the same event forever.

Per-event rejection codes

codeWhat happened
secret_key_requiredAn authoritative event sent with the public key.
missing_user_idMissing user_id on an event that needs it.
future_timestampcreated_at is in the future. Check your server clock.
timestamp_too_oldMore than 10 years in the past. Use the import endpoint for history.
reserved_event_nameThe name collides with a reserved one.
invalid_eventDoesn't match the schema (the message says which field).

Whole-request errors

401 invalid_key · 403 project_inactive · 413 payload_too_large · 429 rate_limited (with Retry-After) · 400 invalid_request

Event types we understand

user.created · user.deleted · user.activated · subscription.started · subscription.cancelled · session.started · page.viewed · your own.

session.started is what feeds daily/weekly/monthly actives; page.viewed deliberately doesn't move that metric (a single session is dozens of views).

POST /v1/users/import

Users you already had before installing us. Secret key only, up to 1,000 per request, 20 requests per hour.

{ "users": [ { "user_id": "usr_1", "created_at": "2025-11-03" } ] }

Response 202: { "imported": 843, "duplicated": 12, "failed": 0 }

They're marked as imported, forever: they count toward your total and it's disclosed on your profile, but they don't count toward growth rankings or the minimum observed users that eligibility requires. It's what keeps importing a thousand made-up users from being the shortcut to first place.

What about old activity? Events accept created_at in the past: send your purchases, orders or sessions from the last few years and your chart has a shape from day one. One rule is strict, though: a user.created dated more than two days ago counts as declared history — same as an import — because we didn't watch that signup happen. It adds to your total, not to what we observed.

What if your buyers never register? Count them anyway. All we need is a stable identifier for that person: their customer id in your system, or their email if there is nothing else (we hash it before storing). If the same buyer comes back with the same identifier, they are not counted twice; if every order sends a new one, you would be counting orders, not people.

GET /v1/status

To confirm the key works and that we're receiving.

{
  "project": { "id": "…", "slug": "plata", "name": "Plata" },
  "key": { "kind": "secret", "prefix": "hmu_sk_live_9f3ac1d2" },
  "live": true,
  "last_event_at": "2026-08-08T20:14:02Z",
  "total_users": 1284,
  "today": { "accepted": 312, "rejected": 0, "duplicated": 4, "last_error": null }
}

GET /v1/verify/<slug>

The only route with no key: anyone can check what a startup claims, without asking us for anything. It returns exactly what its public profile shows — the observation window (since when WE have been counting), users we counted vs users the startup imported, dated milestones, ranking positions. A field the owner turned off is absent from the response, never zero.

{
  "startup": { "slug": "plata", "name": "Plata", "url": "…/en/s/plata", "verification": "continuous" },
  "measured": { "since": "2026-08-09", "days": 4 },
  "users": { "total": 1086, "counted": 1000, "declared": 86 },
  "activity": { "last_7d": 132, "streak_days": 6 },
  "growth": { "last_30d_pct": 55.9 },
  "rankings": [ { "board": "most_users", "scope": "global", "position": 1, "of": 2 } ],
  "milestones": [ { "kind": "users", "value": 1000, "reached_at": "…", "url": "…/m/83bwk5rz4d5qz6k" } ],
  "challenges_won": 1,
  "checked_at": "2026-08-13T12:00:00Z",
  "how_we_verify": "…/en/trust"
}

Optional ?lang=en|es changes only the URLs it returns (profile, milestones, /trust) — never the field names. CORS is open and the response is cacheable for a minute, so a fetch from a browser or a dashboard works as-is.

Embeddables

No key needed — they only draw what the owner published.

GET /api/badge/<slug>.svg?theme=dark|light|neon|minimal&lang=en|es
GET /api/counter/<slug>.svg?theme=…&lang=…

The HTML you paste comes from the panel, and carries the attribution link back (/r/b/<slug>). That link is what lets us know a visit came from THAT badge — instead of "from nowhere", which is what badge traffic looks like without it.

The attribution link takes ?to=proof when you'd rather send the click to the startup's traction proof page instead of its profile: same short link, same attribution, other destination. The badge workshop in your dashboard writes it for you.

Limits

Limit
Events with the secret key600 requests/min per project
Events with the public key120 requests/min per project
Import20 requests/hour (20,000 users)
Body of /v1/events256 KB
Events per request100

Over the limit: 429 with Retry-After in seconds. Batch your events before raising the frequency.

Privacy

We don't want — and don't store — your users' names, emails, phone numbers or addresses. The user_id you send becomes an irreversible hash, different for every project, before it ever touches the database. We measure products, not people.