shovelbasedocs

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.

Why use it

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_startedemail_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

PageWhat it's for
OverviewEvents over time segmented by event name, unique users, and a top-events breakdown — the daily health check
EventsThe live stream of raw events: filter by name, search by name or user, expand any row to see every property
UsersEveryone 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
ChartsYour 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.

SurfaceAvailability
Portal UIOverview, Events, Users, and Charts — view and build dashboards, no write access
CLIshovelbase signals streams the raw event feed, newest first (read-only)
SDK / HTTPshovelbase.signals.* / POST /signals/v1/events — the only way events get in
signals.track()batched in memoryflushPOST /signals/v1/eventsanalytics schemaproject PostgresPortal charts

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 id
shovelbase.signals.identify(user.id); // tie events to a user
shovelbase.signals.track('signup', { plan: 'pro' });
shovelbase.signals.reset(); // on sign-out
await 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).

Swift
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:

signals — /signals/v1/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}
FieldRequiredMeaning
nameyesEvent name (≤ 200 chars), e.g. page_view, signup
distinct_idnoWho did it — a user id or device id; powers the unique-user counts
propsnoJSON object of properties (≤ 8 KB serialized)
tsnoWhen it happened (ISO 8601 or epoch ms); defaults to arrival time. Clamped to the last 7 days / next 5 minutes
insert_idnoIdempotency 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 person
shovelbase.signals.track('signup'); // u-42
shovelbase.signals.alias('legacy-id-9'); // link an id you minted elsewhere
shovelbase.signals.reset(); // sign-out: start a fresh person
RuleWhy
Two account ids never mergeA 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 idsAnything larger is a shared device, not a person. The merge is refused rather than allowed to swallow the project
reset() ends device continuitySign-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.

SetupAnonymous 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 domainUntil 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.