Skip to content
hush

Docs

Everything in one page: run it, wire the SDK, configure it, and the API. The same content lives in the repo's README.

Run it

You need Docker. hush is one container and a Postgres database beside it.

git clone https://github.com/enso-works/hush && cd hush/examples
cp .env.example .env            # set ADMIN_TOKEN and POSTGRES_PASSWORD
docker compose up -d            # http://localhost:3000

Register your app and mint a write key for it. The key is printed once; only its hash is stored.

docker compose exec hush node src/cli.mjs apps:add myapp "My App"
docker compose exec hush node src/cli.mjs keys:create myapp prod

Open http://localhost:3000/dashboard/ and sign in with your ADMIN_TOKEN. It looks like the live demo, with your data instead of invented apps.

Wire the SDK

The SDK is one TypeScript file for Expo and React Native. Copy sdk/src/index.ts into your app (for example as src/lib/hush.ts). It needs @react-native-async-storage/async-storage, expo-constants, expo-device and expo-localization.

import * as hush from '@/lib/hush';

hush.configure({
  url: 'https://hush.example.com',
  key: __DEV__ ? '' : 'hush_myapp_prod_…', // empty key: the SDK stays off
});
hush.init(); // once, early; safe to call again, never throws

Then, anywhere in the app:

hush.screen('Settings');
hush.track('workout_completed', { minutes: 20, completed: true });
hush.identify({ pro: true, rcId: customerInfo.originalAppUserId });
  • Event names are snake_case; props are one flat level of strings, numbers, booleans or null.
  • app_first_opened and session_started are sent for you. A session ends after 30 minutes in the background.
  • Events queue on the device (500, up to 7 days), go out in batches of 20, every 30 seconds, a few seconds after something happens, and when the app goes to the background.
  • The write key ships inside the app, so it is not a secret: it identifies the app, can be revoked, and can read nothing but the calling install’s own feedback.

Feedback

const r = await hush.createTicket({ kind: 'issue', message, email, subject });
// r.ok and r.id, or r.error: 'offline' | 'too_many' | 'failed' | 'unavailable'

const tickets = await hush.listTickets();       // with replies and `unread`
await hush.replyToTicket(ticket.id, 'Thanks!'); // error 'closed' once closed

kind is issue, feature or love. A user can send five a day. Your replies from the dashboard appear in listTickets(), flagged unread once, and are emailed to the user if they left an address.

Options

Option
url the hush server, no trailing slash
key a write key; empty turns the SDK off
storagePrefix AsyncStorage key prefix, default hush. Changing it gives every install a new id.
runInBackground wraps the flush when the app goes to the background, e.g. in a native background task

Configure it

Only DATABASE_URL and ADMIN_TOKEN are required.

Variable
DATABASE_URL Postgres. Migrations run at every boot, before listening.
ADMIN_TOKEN Guards /admin/* and the dashboard’s data. openssl rand -hex 32.
APPS Register apps at boot: myapp=My App,other=Other.
CATALOG_FILE Each app’s known events and its highlight metric, as JSON.
CLIENT_IP_HEADER Header a trusted proxy sets with the caller’s address, for rate limits. Unset: the socket address.
COUNTRY_HEADER Header a trusted proxy sets with a two-letter country. Unset: no country.
RESEND_API_KEY, MAIL_FROM Email through Resend: feedback alerts, and replies to users who left an address.
ALERT_EMAIL, REPLY_HINT Where new feedback is announced (at most 30 an hour), and a line saying where to answer.
RETENTION_DAYS Raw events older than this are deleted. Default 180.
RC_API_KEY RevenueCat v2 secret key with read-only scopes, for revenue on the dashboard.

The event catalog

The catalog names the events you expect. Anything else is still stored, never dropped, and flagged as unknown on the dashboard so you can fix the typo or add the name. It also names one highlight: the event the dashboard counts per period, and the boolean prop that marks it done.

{
  "myapp": {
    "events": ["workout_started", "workout_completed"],
    "highlight": { "event": "workout_completed", "done_prop": "completed" }
  }
}

Deploy it

Put hush behind a proxy that terminates TLS, then:

  • expose /v1/* and /healthz to the internet: that is what apps talk to;
  • keep /dashboard/ and /admin/* behind a VPN or an access proxy. The admin token is the second lock, not the only one;
  • set CLIENT_IP_HEADER and COUNTRY_HEADER only to headers your proxy overwrites;
  • back up Postgres. It is the only state.

Rate limits are in memory and per process (120 requests a minute per address, 5 feedback messages a day per install). One instance is plenty for small apps.

What is collected

  • A random install id the app creates on first launch; the only identifier.
  • App version, OS, device model, the phone’s language, and the events and props your app sends.
  • Feedback messages, and an email address only if a user typed one.
  • Country, only with COUNTRY_HEADER behind a trusted proxy; the dashboard folds any country under ten installs into “other”.
  • Never an IP address. Rate limits count a salted hash held in memory.

Raw events are deleted after RETENTION_DAYS; install rows and feedback are kept. Write your privacy policy from this list, and keep your props to what the app did, not who did it.

API

Apps send Authorization: Key <write key>. The SDK does this for you.

Endpoint
POST /v1/events Up to 100 events. 200 { accepted, duplicate, rejected }. Any 4xx but 429 means never: drop the batch. 429 and 5xx: retry later.
POST /v1/tickets { install, kind, message, email?, subject? } → 201 { id, created_at, status }.
GET /v1/tickets?install= That install’s feedback, with replies and unread.
POST /v1/tickets/:id/reply { install, body } → 201, or 409 once closed.
GET /healthz { ok, db }

The operator side takes Authorization: Bearer <ADMIN_TOKEN> and is what the dashboard reads: /admin/apps, /admin/apps/:app, /admin/tickets, /admin/tickets/:id with /reply and /status, and /admin/revenue.

The command line, inside the container:

node src/cli.mjs apps:list | apps:add <slug> <name>
node src/cli.mjs keys:create <app> <prod|dev> [label] | keys:list | keys:revoke <id>
node src/cli.mjs rc:projects | rc:sync | rc:link <app> <project> | rc:poll

The demo

The live demo is a hush instance started with DEMO=1: three invented apps with sixty days of usage and a few feedback threads, regenerated every day, read-only, and closed to app data. You can run the same thing locally to try the dashboard before wiring anything. A demo wipes its database on start, so it refuses to run against one that holds any write key.