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_openedandsession_startedare 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/healthzto 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_HEADERandCOUNTRY_HEADERonly 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_HEADERbehind 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.