Signals
Product analytics: your apps send named events with properties, and the portal charts them under Signals. Events land in an analytics schema inside the project's own Postgres database. That schema is not exposed through /rest/v1, so API keys can write events but never read them back — which is what makes it safe to ship the anon key in browsers and mobile apps.
Logs tell you what your servers did; Signals tells you what your users did. Those are different questions, and the second one can't be reconstructed after the fact — if you didn't record that someone opened the checkout screen and then left, no amount of querying production tables later will recover it. Your database holds current state (this user has a subscription), not the sequence of actions that produced it.
The design point here is that events go to the same Postgres instance as the rest of your project, in their own schema. So the raw event table is joinable against your application tables — “funnel conversion, but only for accounts on the pro plan” is a SQL query, not an export job between two vendors. And no user data leaves for a third-party analytics service, which is often the deciding factor for anything privacy-sensitive.
Instrument deliberately: a handful of events named after user intent (checkout_started, invite_sent) is far more useful than tracking every click. Event names are effectively permanent — charts and funnels are built on them, so renaming one orphans its history.
- Funnels — where people drop off
- Track each step (
signup_started→email_confirmed→first_project_created) and the multi-step funnel chart shows which step loses people. - Did the feature land?
- Pair with a feature flag: flip it on, then compare event volume before and after to see whether anyone actually used the thing.
- Debugging a specific user's report
- “It didn't work for me yesterday” — the Users page has a per-user activity feed, so you can see the exact sequence of actions they took.
- Retention and engagement
- Unique users and events-per-user over 7/30/90 days, to tell “lots of traffic once” apart from “people keep coming back.”
The four pages
| Page | What it's for |
|---|---|
| Overview | Events over time segmented by event name, unique users, and a top-events breakdown — the daily health check |
| Events | The live stream of raw events: filter by name, search by name or user, expand any row to see every property |
| Users | Everyone your apps have seen — one row per person, not per distinct_id — with event counts, first/last seen, and an activity feed spanning all of their ids |
| Charts | Your own dashboard — saved charts built from a metric, filters, and a breakdown (below) |
On Charts you pick a metric (total events, unique users, or events per user, over all events or specific ones), add property filters (is / is not / contains / is set / is not set, with value suggestions drawn from your own data), and break results down by event name or any property. Results render as line, area, column, bar, donut, stat, table, or multi-step funnel charts, across 24-hour / 7-day / 30-day / 90-day ranges.
| Surface | Availability |
|---|---|
| Portal UI | Overview, Events, Users, and Charts — view and build dashboards, no write access |
| CLI | shovelbase signals streams the raw event feed, newest first (read-only) |
| SDK / HTTP | shovelbase.signals.* / POST /signals/v1/events — the only way events get in |
JavaScript / TypeScript
Every shovelbase-js client has a signals handle. Events are batched in memory and flushed every 10 seconds, at 20 queued events, and when the page is hidden — track() never blocks and never throws.
const shovelbase = createClient(SHOVELBASE_URL, SHOVELBASE_ANON_KEY); shovelbase.signals.track('page_view'); // anonymous device idshovelbase.signals.identify(user.id); // tie events to a usershovelbase.signals.track('signup', { plan: 'pro' });shovelbase.signals.reset(); // on sign-outawait shovelbase.signals.flush(); // force-send (tests, CLIs) // Standalone (no database/auth client needed):import { ShovelbaseSignals } from 'shovelbase-js';const signals = new ShovelbaseSignals(SHOVELBASE_URL, SHOVELBASE_ANON_KEY);iOS / Swift
The ShovelbaseSignals Swift package (iOS, macOS, tvOS, watchOS) queues events on disk — they survive app kills — and flushes on a timer, at batch size, and when the app is backgrounded. It ships in the same shovelbase-swift package (add ShovelbaseSignals instead of, or alongside, Shovelbase).
ShovelbaseSignals.configure(url: "https://<ref>.shovelbase.com", apiKey: ANON_KEY) ShovelbaseSignals.shared.identify(user.id)ShovelbaseSignals.shared.track("purchase", properties: ["sku": "pro", "price": 9.99])HTTP API (/signals/v1/events)
The SDKs are thin wrappers over one endpoint, so any HTTP client can send events:
curl -X POST "https://<ref>.shovelbase.com/signals/v1/events" \ -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \ -d '{"events": [ {"name": "signup", "distinct_id": "u-42", "props": {"plan": "pro"}, "ts": "2026-07-06T12:00:00Z", "insert_id": "b3f1c1e2-…"} ]}'# → {"ok": true, "ingested": 1}| Field | Required | Meaning |
|---|---|---|
name | yes | Event name (≤ 200 chars), e.g. page_view, signup |
distinct_id | no | Who did it — a user id or device id; powers the unique-user counts |
props | no | JSON object of properties (≤ 8 KB serialized) |
ts | no | When it happened (ISO 8601 or epoch ms); defaults to arrival time. Clamped to the last 7 days / next 5 minutes |
insert_id | no | Idempotency key — a retried batch is never double-counted |
Up to 100 events per request. ts is clamped into a window around now — no earlier than 7 days ago, no later than 5 minutes ahead — so events queued on a device with a wrong clock still land somewhere sane, but historical backfill is not possible: importing a year of events from another tool would file them all under the 7-day floor. The key can come as an apikey header, Authorization: Bearer, or an ?apikey= query parameter (for navigator.sendBeacon). Raw events are browsable on the Events page and queryable in the SQL Editor — select * from analytics.events — so you can build your own reports, and the aggregates are available to scripts via the management API.
Identity — who a person is
Until you call identify(), events carry an anonymous id ($anon-…) that the SDK mints once and stores — in localStorage on the web, UserDefaults on iOS — so a returning visitor is the same person, not a new one. There is no fingerprinting: nothing is derived from the device, IP, or browser characteristics.
When identify() runs, the SDK reports both ids and shovelbase records that they are the same person. Everything the visitor did before signing up stays attached to their account, so a funnel can span sign-up and unique-user counts stop counting one human twice. The id is persisted too, so a page reload keeps them identified — call identify() on every load, it's a no-op when nothing changed.
shovelbase.signals.track('landing_view'); // $anon-3f2a… (anonymous)shovelbase.signals.identify('u-42'); // → both ids are now one personshovelbase.signals.track('signup'); // u-42 shovelbase.signals.alias('legacy-id-9'); // link an id you minted elsewhereshovelbase.signals.reset(); // sign-out: start a fresh person| Rule | Why |
|---|---|
| Two account ids never merge | A shared laptop or kiosk would otherwise chain everyone who signed in on it into one person. Applies transitively, so linking through a shared device is refused too |
| A person stops merging past 20 ids | Anything larger is a shared device, not a person. The merge is refused rather than allowed to swallow the project |
reset() ends device continuity | Sign-out means the next person on this browser starts clean instead of inheriting the last one's identity |
Reserved $-prefixed events | $identify, $alias, and $reset are identity plumbing. They are stored (visible on Events) but never counted in charts, the builder, or the Users list |
Resolution applies at query time, so the Users page lists people rather than ids — labelled with the account id, and badged with how many ids merged into it. Raw analytics.events rows are never rewritten; the graph lives alongside them in the identity schema, both readable from the SQL Editor. Identity is recorded from the moment you deploy this — links that were never captured can't be reconstructed, so history from before stays as it was.
Anonymous identity that survives storage clearing
Browsers evict script-writable storage aggressively — Safari caps it at seven days — so an anonymous visitor can come back looking brand new. Where the project API shares your site's domain, shovelbase also sets a first-party sb_did cookie and uses it to reconnect a cleared visitor to who they were. It is HttpOnly, holds nothing but an opaque id, and no code of yours has to touch it.
| Setup | Anonymous identity survives |
|---|---|
Site hosted on shovelbase (<ref>.shovelbase.com) | Fully — the API is same-origin, so the cookie is first-party |
Custom domain on the project API (api.example.com) | Fully — same-site as your app, in every browser |
Default <ref>.shovelbase.com API, app on another domain | Until storage is cleared — the cookie is third-party there and browsers drop it, so identity falls back to localStorage alone |
Nothing to configure either way: attaching a domain to the project API is what upgrades the middle row, and everything else already works.