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 lives | In the browser. It's public by design. | Server only. |
| Can send | page.viewed, session.started, your custom events | Everything the public key can, plus user.created, user.deleted, user.activated, subscription.* |
| Can import | No | Yes |
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
{ "event": "user.created", "user_id": "usr_123", "created_at": "2026-08-08T20:00:00Z" }{ "events": [ { "event": "user.created", "user_id": "usr_123" }, … ] }Fields
| Field | Type | Notes |
|---|---|---|
| event | required | Lowercase, up to three levels: checkout.completed. Can't start with user., subscription. or hmu. unless it's one of ours. |
| user_id | required for user events | A stable, non-personal id. We hash it before storing it, but sending emails anyway is still wrong — we don't need them. |
| created_at | optional | ISO 8601. Defaults to now if missing. 2026-08-08 is accepted too (anchored to noon UTC). |
| idempotency_key | optional | If you retry, the event isn't counted twice. |
| props | optional | A 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
| code | What happened |
|---|---|
| secret_key_required | An authoritative event sent with the public key. |
| missing_user_id | Missing user_id on an event that needs it. |
| future_timestamp | created_at is in the future. Check your server clock. |
| timestamp_too_old | More than 10 years in the past. Use the import endpoint for history. |
| reserved_event_name | The name collides with a reserved one. |
| invalid_event | Doesn'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 key | 600 requests/min per project |
| Events with the public key | 120 requests/min per project |
| Import | 20 requests/hour (20,000 users) |
| Body of /v1/events | 256 KB |
| Events per request | 100 |
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.