grovs
v2.1.0
Published
Grovs generates dynamic links, tracks attributions, and analyzes referrals, seamlessly directing users to your app or the app store for downloads.
Maintainers
Readme
Overview
Grovs is a JavaScript SDK for deep linking, attribution and in-app messaging on the web. It generates dynamic links, resolves deferred deep links, tracks analytics events, and displays messages from the Grovs console.
Works in bundled browser apps, SSR frameworks (Next.js, Nuxt), Electron, and
from a plain <script> tag. Written in TypeScript, ships its own types, and
has no runtime dependencies.
Installation
npm install grovs --saveimport Grovs from "grovs";Script tag
The IIFE build at dist/grovs.global.js exposes window.Grovs. Serve it from
your own origin alongside your other assets:
<script src="/assets/grovs.global.js"></script>
<script>
Grovs.configure({ apiKey: "your-api-key" });
</script>Loading it from a public CDN works too, but pin an exact version and add a
Subresource Integrity hash — a @2 range resolves to a different file on every
patch release, which defeats SRI and leaves you executing whatever the CDN
serves. The published release notes carry the integrity value for each
version.
CommonJS
import is the primary path. From require, the facade is the default
export and the named exports sit alongside it:
const { default: Grovs, GrovsError } = require("grovs");
await Grovs.configure({ apiKey: "your-api-key" });TypeScript
Types ship with the package; no @types/grovs is needed.
Upgrading from v1
Your existing v1 code keeps working. Two accessors return corrected values — see MIGRATION.md before upgrading.
Configuration
Call configure() once, as early as you can. It resolves true when the SDK
authenticated successfully.
await Grovs.configure({
apiKey: "your-api-key",
testEnvironment: false,
onDeeplink: (payload) => {
// The app was opened from a Grovs link.
console.log("deep link payload", payload);
},
onError: (code, message) => {
console.error("grovs error", code, message);
},
});| Option | Default | Purpose |
|---|---|---|
| apiKey | required | From the console at app.grovs.io |
| testEnvironment | false | Routes to the test environment |
| baseURL | Grovs cloud | Custom API domain for self-hosted backends |
| autoTrackScreenViews | true | Fires screen views on SPA route changes |
| cookieDomain | host-only | Set to span subdomains, e.g. .example.com |
| requireConsent | false | Store and send nothing until grantConsent() |
| appVersion | SDK version | Your app's version, reported with the device fingerprint |
| debugLevel | "error" | "info", "warn" or "error" |
| messagesTheme | — | Deprecated and ignored — see Messages inbox (removed) |
| onDeeplink | — | Called with the payload when opened from a link |
| onError | — | Called on every SDK failure |
Server-side rendering. Importing the SDK during SSR is safe. Calling
configure()on the server is a no-op that logs once and reports throughonError— call it on the client instead.
Linked domains
The SDK sends its origin (https://app.example.com) as an identifier, and the
backend compares it exactly against the linked domains configured for your
project. A mismatch fails with "This Web app is not configured". If that
happens, the error message contains the exact string the SDK sent — copy it
into the console.
Deep links
Deep link handling needs no integration beyond onDeeplink. The SDK reads the
link parameter from the URL, resolves the payload, and calls you back.
await Grovs.configure({
apiKey: "your-api-key",
onDeeplink: (payload) => router.push(payload.screen),
});
// Or read them later:
Grovs.lastReceivedPayload();
Grovs.allReceivedPayloadsSinceStartup();The callback runs when a lookup returns a payload object; a missing payload
does not trigger it. With requireConsent: true, the lookup and callback wait
for grantConsent(), while the URL's link token is captured in memory first.
Refreshing with ?Grovs=... (or the legacy ?linksquared=...) still in the URL
resolves that link again and delivers its payload to the new page again. The
SDK consumes the stored token after a successful lookup but leaves the URL
unchanged. If your app handles a link only once, remove its query parameter
after handling it and guard any action that must not repeat. Without a URL
token, the backend can still return a payload through deferred matching.
Generating links
const url = await Grovs.generateLink({
title: "Link title",
subtitle: "Link subtitle",
imageURL: "https://example.com/image.jpg",
data: { screen: "product", id: 42 },
tags: ["launch"],
showPreviewiOS: false,
showPreviewAndroid: true,
copyToClipboardiOS: false,
copyToClipboardAndroid: true,
customRedirects: {
ios: { link: "https://apps.apple.com/...", openAppIfInstalled: true },
android: { link: "https://play.google.com/..." },
desktop: { link: "https://example.com" },
},
trackingCampaign: "BlackFriday2025",
trackingSource: "instagram",
trackingMedium: "social",
});Resolves null on failure; the reason arrives through onError.
copyToClipboardiOS / copyToClipboardAndroid ask the Grovs-hosted preview page
to copy the link to the clipboard, so a fresh mobile install can be matched back
to it by the native SDK. Omit them and the link inherits the project default —
passing false is an explicit override, not the same thing. They only take
effect where the preview page is shown for that platform (showPreviewiOS /
showPreviewAndroid), and there is no combined copyToClipboard shorthand;
the toggles are per-platform by design. The web SDK never reads the clipboard
itself: the copy happens on the preview page and the read-back happens in the
iOS and Android SDKs after install.
Details for a link the SDK generated:
const details = await Grovs.linkDetails("abc123");Identifying users
Grovs.setUserIdentifier("user-42");
Grovs.setUserAttributes({ plan: "pro", seats: 5 });
Grovs.userIdentifier; // "user-42"
Grovs.userAttributes; // { plan: "pro", seats: 5 }Both are safe to call before configure() resolves. Whichever you set locally
wins; the other is adopted from the server if it has one.
Grovs.isAuthenticated() reports whether the SDK has authenticated. It is
false before configure() resolves, after reset(), and while a transient
failure is being retried.
Tracking events
Grovs.track("purchase", { item: "sku-42", price: 19.99 }, ["checkout"]);
Grovs.trackScreenView("Checkout", { section: "payment" });Custom events carry the most recently viewed screen as screen_name, so they
can be segmented by screen. Screen names are capped at 255 characters.
Event names must be non-empty and must not be a reserved system name
(install, reinstall, app_open, view, open, time_spent,
reactivation, user_referred) or a message event (push_open,
push_delivered, push_held, in_app_viewed, in_app_clicked,
in_app_dismissed, in_app_answered, in_app_commented, in_app_held,
journey_entered, journey_held, journey_path); the backend rejects them too.
Use trackScreenView() for screen views.
Property values may be strings, numbers, booleans, null, or nested
arrays and objects of those. Date, URL and BigInt are coerced to
strings. Values that cannot be represented in JSON — NaN, Infinity,
functions, symbols, circular references — are dropped for that key only; the
rest are still sent. If the encoded properties exceed 8 KB (measured as UTF-8
bytes, as the backend measures them) they are all dropped and the event is
still recorded. Tags are capped at 20 per event, each 255 characters.
Screen tracking
With autoTrackScreenViews enabled (the default), SPA route changes fire
screen views automatically — pushState, replaceState, popstate and
hashchange are all observed. The screen name resolves in priority order:
screenNameProvider, then the alias map, then document.title, then the
pathname.
Name resolution is deferred one animation frame, so document.title is the
page the user is on rather than the one they left. If your framework commits
its render later than that, name the screen explicitly:
Grovs.screenNameProvider = (url) => {
if (url.pathname.startsWith("/legal")) return "suppress";
if (url.pathname.startsWith("/product/")) return "Product";
return "automatic";
};URLs are high-cardinality, so collapse them with alias patterns — otherwise a catalogue of any size produces one dashboard row per id:
Grovs.setScreenAliases({
"/product/:id": "Product",
"/docs/*": "Documentation",
"/checkout": "Checkout",
});When several patterns match, the more specific one wins regardless of order:
more literal text first, then :param over *. A bare * is a catch-all
that every other pattern beats.
Global tags attach to every event until cleared:
Grovs.setGlobalTags(["beta"]);
Grovs.setGlobalTags(null);Flushing
Events batch automatically and flush on tab close. To drain the queue before a hard navigation:
await Grovs.flush();Messages inbox (removed)
The messages inbox and automatic message display were removed; in-app messages replace them. The methods remain as deprecated no-ops so existing pages keep working, and none of them makes a request:
| Method | Now |
| --- | --- |
| Grovs.showMessagesList() | does nothing |
| Grovs.displayAutomaticMessages() | does nothing |
| Grovs.getMessages(page) | resolves [] |
| Grovs.numberOfUnreadMessages() | resolves 0 |
| Grovs.markMessageAsRead(id) | resolves true |
The messagesTheme option is still accepted and ignored.
In-app HTML messages and CSP
An HTML in-app message runs in a sandboxed srcdoc iframe, which inherits your
page's Content-Security-Policy. The SDK cannot override it, so the policy must
allow what the message uses:
script-srcmust allow inline scripts: the message's bridge and its own scripts are inline. That means'unsafe-inline'and no nonce, hash or'strict-dynamic'inscript-src(or indefault-srcwhen there is noscript-src): browsers ignore'unsafe-inline'when any of those is present. So a nonce-, hash- or'strict-dynamic'-based policy cannot run HTML messages at all. When blocked, the message never starts; the SDK skips it and reportsonErrorwithinAppDisplayFailed(5) once, naming the CSP.img-src,media-srcandfont-src: the hosts the message loads pictures, video and fonts from (your CDN, orhttps:).img-srcalso covers the pictures of the built-in templates.style-src 'unsafe-inline'if the message uses<style>orstyle="".
The built-in templates need no script or style exception, so they work under
any policy, including nonce-, hash- and 'strict-dynamic'-based ones.
Smart banner
On iPhone, iPad and Android browsers the SDK shows the banner configured in the Grovs dashboard (Link behaviour → Smart banners). Its button is a Grovs link: it opens your app when installed, and otherwise goes to the store and delivers the deep link after install. Desktop browsers never see it.
Tell the banner what the current page is about, so "Open in app" lands on the
right screen. Call it on each page of a single-page app; calls made before
configure() apply once it runs:
Grovs.smartBanner.setLinkData({ data: { product_id: "42" } });The app receives your data plus page_path (the page's path, unless you
pass your own). data is a flat object of at most 20 keys with string,
number or boolean values; keys are at most 64 characters, string values at
most 512, and the whole payload at most 4 KB. Data over a limit is not sent:
the banner keeps its default link and the SDK logs a warning.
Pages that never call setLinkData() use the banner's default link, with no
extra request. A tap before the page link arrives uses the default link. If
the banner or link request fails, the SDK waits 60 seconds, or longer if the
server's Retry-After asks, across page loads in the tab, before asking
again; a banner already showing stays.
Grovs.smartBanner.hide(); // this page only; not a dismissal
Grovs.smartBanner.refresh(); // re-run banner selection
Grovs.configure({ apiKey: "…", smartBanner: false }); // turn it offThe dashboard sets two separate hide periods: after closing, the banner is
hidden for dismiss_days; after opening (tapping the button), it's hidden
for open_hide_days. Either can be 0, hiding the banner for that page view
only.
The SDK follows route changes in single-page apps on its own, whatever
autoTrackScreenViews is set to. A new page is a new path (see below);
query-string and #anchor changes keep the link data you set. A query-string
change on the same path is the same page, so if your product id lives in the
query string (/product?id=42), call setLinkData() when it changes. Under
requireConsent the banner waits for grantConsent(). Values that are
undefined or null are left out, so { product_id: product?.id } is fine.
The v1 constructor (new Grovs(apiKey, …)) never shows the banner; use
Grovs.configure() to get it.
If two copies of the SDK run on one page, only one banner is drawn. An older
copy (released before the smart banner) that owns SPA route tracking can hide
pushState route changes from the banner; full page loads and back/forward
still work.
Hash-routed sites
When the URL hash starts with #/ or #!/ (shop.com/#/product/42), the
hash is the page path: /product/42, without the hash's own ?query or
#anchor. It is what URL patterns match, what page_path defaults to, and
what counts as a new page. Other hashes (#reviews) are plain anchors and
the path is location.pathname. An app mounted under a path
(/app/#/product/42) matches on the hash route alone, so write patterns
like /product/*, not /app/product/*.
Fixed headers and footers
A top banner pushes the page down; a bottom banner floats over it. Offset
your own fixed elements with the two variables the SDK sets on <html>:
.site-header { top: var(--grovs-smart-banner-top, 0); }
.cookie-bar { bottom: var(--grovs-smart-banner-bottom, 0); }Webviews
The banner also shows inside your own app's webview: pass
smartBanner: false there. In Instagram and Facebook in-app browsers the
button may not be able to open the app.
Purchases
await Grovs.logCustomPurchase({
type: "buy", // "buy" | "cancel" | "refund" | "refund_reversed"
priceInCents: 1999,
currency: "USD",
productID: "com.acme.coins.100",
startDate: new Date(), // optional
transactionID: "order-8842", // optional, but pass yours — see below
});The backend deduplicates purchases on the transaction id, so passing your own order or payment id makes the call safe to repeat: a retry after a timeout, or a double-submitted checkout, is counted once. Omit it and the SDK mints one per call, which covers its own network retries but cannot recognise the same purchase sent from a later page load.
Purchase events require a Grovs Enterprise backend (
GROVS_EE=true). On a standard deployment the endpoint does not exist and this reportseventDispatchFailednaming the requirement rather than retrying.
Consent
Tracking is on by default. To gate it behind a cookie banner:
await Grovs.configure({ apiKey: "your-api-key", requireConsent: true });
// Nothing has been stored on the device or sent. Events tracked meanwhile are
// held in memory and delivered once consent arrives.
await Grovs.grantConsent();
// Clear identifiers, session and queued events:
Grovs.reset();reset() returns the SDK to its pre-consent state and stops tracking. It
does not re-authenticate on its own, which is the point: with
requireConsent: true the visitor has withdrawn permission. To start again,
call configure() (then grantConsent() if you require consent).
Delivery
Events are queued, persisted to localStorage, and sent in batches of 50.
The first batch goes as soon as configure() has authenticated and resolved
attribution, and it carries anything an earlier visit left undelivered, so a
short visit does not depend on the tab-close flush. After that they batch
every five seconds, immediately at 50 events, and again when the page is
hidden.
Failed requests are attempted up to three times, with exponential backoff and
full jitter. A Retry-After longer than a request will wait ends the retries
and holds the scheduled batches for the interval the server named; an
explicit flush() still sends.
flush() joins a delivery already in progress rather than starting a second
one. If that delivery is a scheduled batch and the backend throttles it,
flush() resolves with events still queued — it waits for the send in
flight, not for the queue to empty. Call it again, or let the next scheduled
batch carry them once the server's interval has passed. Events the backend rejects as permanently
invalid are dropped rather than retried. The queue holds 1,000 events, or one
million characters, and discards anything older than seven days.
What the SDK collects
configure() authenticates and, in the same request, reports a device
fingerprint used to match deferred deep links: user agent, screen size,
timezone, language, and the WebGL vendor and renderer strings. It also reads
and writes a visitor identifier in a cookie and in localStorage. Tracked
events carry the event name, your properties and tags, a session id, and the
attribution path the visit arrived on.
On mobile browsers the smart banner fetches its configuration and, for the
banner it shows, sends the page path and any link data you passed to
setLinkData(). It stores dismissals in localStorage
(grovs_smart_banner_dismissed) and caches its configuration and links in
sessionStorage (grovs_sb:* keys), and tracks the smart_banner_view,
smart_banner_click and smart_banner_dismiss events.
With the default requireConsent: false this happens as soon as you call
configure(). If you need it gated behind a banner, use
Consent — with requireConsent: true nothing is written to the
device and no request leaves until grantConsent().
Errors
Every failure reaches onError with one of five codes, matching the iOS SDK.
Code 5 is new in 2.1.0; an exhaustive switch needs a case for it.
| Code | Meaning |
|---|---|
| 1 authenticationFailed | Bad API key, unconfigured domain, or auth failure |
| 2 networkRequestFailed | A request failed, or the SDK was called during SSR |
| 3 eventDispatchFailed | Events were rejected; they stay queued unless permanently invalid |
| 4 linkGenerationFailed | Link generation failed |
| 5 inAppDisplayFailed | An in-app message could not be drawn, for example an HTML message blocked by the page's CSP |
import Grovs, { GrovsError } from "grovs";
await Grovs.configure({
apiKey: "your-api-key",
onError: (code, message) => {
if (code === GrovsError.authenticationFailed) reportToSentry(message);
},
});Disabling the SDK
Grovs.setEnabled(false);
Grovs.setDebugLevel("info");Known limitations
- Safari identity. The visitor identifier is written to both a cookie and localStorage, because Safari's ITP clamps script-written cookies to 7 days regardless of the expiry requested. That covers visitors returning within the window, but ITP can still evict script-writable storage for genuinely dormant ones — so Safari install counts carry a small known over-count.
- Delivery is at-least-once. The final batch is sent with
keepalivewhen the page is hidden and stays stored until acknowledged, so a page that goes away before the answer sends it again from the next load. No batch is dropped before an acknowledgement. Events can still be discarded for the documented reasons below: seven days old, evicted by the queue caps, or never written because the browser refused storage. A re-send is byte-identical and carries the sameevent_id, so a backend that deduplicates on it counts the event once. Grovs cloud does. If you pointbaseURLat your own backend, deduplicating onevent_idis your responsibility; without it, retries are counted twice. - One known exception to "byte-identical", across tabs. An event is
attributed by the page that created it, and only that page. A second tab
sharing the queue can send a copy of that event before its own page has
settled its attribution, and the two copies then differ in the campaign
they carry. Grovs cloud's
event_idcovers the resolved campaign, so those two are not recognised as the same event and one visit can be counted twice under two campaigns. It needs a second tab, opened on a campaign link, while the first tab's attribution is still resolving. Closing it properly needs per-tab queue ownership, which is not in this release. configure()resolvesfalseon a transient failure (no connection, timeout, 429, 5xx) while the SDK retries in the background, ononlineand at 30-second intervals, up to three times. Events tracked meanwhile queue.- Multiple tabs share one queue without atomic ownership. A tab opened
mid-visit may send events the first tab also sends. Those copies are
identical and
event_iddedup collapses them, except in the attribution case above. Because the shared write is read-merge-write andlocalStoragehas no compare-and-swap, two tabs writing in the same instant can still lose an event that neither has delivered. - Stored state is per project. The queue, session and counters are keyed
by project (and separately for
testEnvironment), so switching projects on one origin does not carry events across. The visitor identifier is shared. - Purchases require Enterprise, as above.
- In-app purchase logging has no web equivalent; there is no StoreKit.
logCustomPurchasecovers every web payment flow.
Development
npm test # typecheck, lint, unit tests, coverage gate
npm run verify # the above plus build, size budget, export shapes, E2E
npm run demo # QA harness at http://localhost:5174/demo/
npm run test:live # every flow against a real backend (needs a key)
npm run test:safari # native installed Safari on macOS, local test serverThe demo runs stubbed by default — no backend needed — and can be switched to
a real one. npm run test:live drives it through every flow against a live
project: create a link, arrive through it, resolve the payload, deliver events
and confirm the backend accepted them, identity, purchases. See
demo/README.md.
The browser suite includes a complete consent journey, payload delivery on
refresh, and responses arriving after reset. These use controlled HTTP
responses; e2e/delivery.spec.ts separately checks real HTTP receipt. They run
in Chromium, WebKit and Firefox with no retries.
They start a fresh server on port 4175, separate from the demo on port 5174,
so a running dev server cannot serve a stale SDK bundle. Live tests require
http://localhost:4175 in the project's linked domains and CORS configuration.
For concurrent local runs, set GROVS_E2E_PORT to a free port and give
Playwright separate output/report directories (--output and
PLAYWRIGHT_HTML_OUTPUT_DIR). Live projects must allow the overridden origin.
On a browser-test failure, test-results/ contains a screenshot and trace;
playwright-report/ contains the HTML report. Open it with
npx playwright show-report. CI uploads these folders on failure.
An explicit npm run test:live builds the SDK and fails if
GROVS_LIVE_API_KEY is missing; the normal verification suite needs no key.
npm run test:safari uses macOS's bundled safaridriver to run the built SDK
in the installed Safari. Enable Safari → Settings → Developer → Allow remote
automation first. It opens an isolated automation window; keep that window
in front during the tab-close test. It needs no project key and sends all SDK
traffic to a local HTTP server. Failed setup fails the command rather than
silently skipping tests.
The native Safari suite checks consent, request headers and browser details, deep-link callbacks across refresh and delayed consent, automatic batching, identity recovery after cookie deletion, full storage loss, refused storage writes, retries, unchanged replay after reload, and server receipt after a real tab close. Storage deletion/refusal is injected: this tests recovery from eviction, not Safari's multi-day ITP eviction policy. The IP assertion checks the local connection address; production proxy IP forwarding must be verified against the deployed backend separately.
Further assistance
Documentation: https://docs.grovs.io/s/docs. Support: [email protected].
