Workspace
Security, privacy and your data
Read-only access, credential encryption, what is and isn't stored about visitors and customers, consent, and what happens when you disconnect.
Joinsight reads your billing, search and traffic accounts and joins them to what a small script records on your site. That is a lot of access, so this page sets out exactly what it is: what Joinsight can do with each account, how credentials are kept, what is and isn't stored about your visitors and customers, what an AI model gets to see, and what happens when you disconnect something. The privacy policy is the binding version; this page explains the mechanics behind it.
The short version
- Every source connection is read-only. Stripe only accepts a restricted key with Read permissions; secret keys are refused.
- Each stored credential is encrypted with AES-256-GCM under its own data key.
- Every workspace's data sits behind row-level security in the database, so one workspace's queries can't return another's rows.
- Customer emails are stored only as a keyed hash plus a masked form such as
j•••@e•••.com. - The snippet stores nothing on a visitor's device before consent, and Joinsight stores no visitor IP addresses.
- Claude, when it's used for sentence rewriting, only receives aggregates and labels.
- The MCP server is read-only, bound to one workspace per connection, and logs every call.
What Joinsight can and cannot do with your accounts
| Account | What Joinsight holds | What it can do | What it can't do |
|---|---|---|---|
| Stripe | A restricted key (rk_live_ or rk_test_) | Read customers, subscriptions, invoices, prices, products, coupons, events, payment methods and charges | Create charges, refunds, customers or anything else. Secret (sk_) and publishable (pk_) keys are rejected at connect time |
| RevenueCat | An API v2 secret key with Read only access to Customer information, Project configuration and Charts metrics, plus a webhook secret | Read customers, subscriptions, purchases and RevenueCat's own MRR figure; receive webhook events you configure | Change products, entitlements or customers |
An OAuth refresh token with webmasters.readonly and analytics.readonly, plus openid and email to show which account is connected | Read Search Console and GA4 reports | Change Search Console or Analytics settings | |
| Plausible | A Stats API key and site ID | Read visitors by entry page and source | Change your Plausible site |
| App Store Connect | One stored key, meant to be a Sales and Reports or Finance key. An Admin key entered to request reports is used for that one call and never stored | Download the analytics reports; the Admin key only creates the report requests | Change your apps, users or listings. If you connected an Admin key as the stored key, the card asks you to swap it for a Sales and Reports key |
An Instagram Login token with instagram_business_basic and instagram_business_manage_insights | Read your professional account's posts and their metrics | Publish, comment or message | |
| MCP clients | Nothing of yours; the client holds a token Joinsight issued | Read MRR, movements, customers, page, keyword and App Store revenue, alerts and data health | Change any data or settings, see your API keys or tokens, see full customer emails, or touch billing or members |
Inside a workspace, only owners can connect or disconnect sources, change notification settings, rotate the secret key and delete the workspace. Members can read the dashboard and connect their own AI assistants.
How credentials are stored
Credentials use envelope encryption. Each value, such as a Stripe key or a Google refresh token, is encrypted with AES-256-GCM under a fresh random data key. That data key is then encrypted with a master key that lives in the server's configuration, not in the database. A copy of the database alone doesn't reveal any credential.
Rotating the master key only re-wraps the data keys; the encrypted values themselves are untouched. A single workspace's credentials can also be revoked on their own.
The same encryption covers your Slack and Discord webhook URLs and the RevenueCat webhook secret. Other secrets are stored as one-way hashes, never in a readable form:
- Your workspace's secret key (
js_sk_…) is shown once: when you create the workspace, and again each time you rotate it in Settings, Keys. Joinsight keeps a keyed hash and the last four characters. - Sign-in links, sign-in codes, sessions and MCP tokens are stored as hashes.
Once you've pasted a key, Joinsight never shows it again. The App Store card, for example, says the .p8 file is "stored encrypted and never shown again".
How workspaces are kept apart
Every table that holds workspace data has a workspace_id column and a forced row-level security policy in Postgres. The application connects as a role that can't bypass those policies. Each request that touches workspace data runs in a transaction that first declares which workspace it's for, and the database then only exposes that workspace's rows. A query that forgets to filter returns nothing, not someone else's data.
The MCP server, the alert rules, the brief and the dashboard all go through the same mechanism.
Sign-in and sessions
Joinsight has no passwords.
- Email sign-in. You get a link and a 6-digit code. Both expire after 15 minutes and work once. The link opens a confirmation page where you press Sign in, and the token travels in the part of the URL after
#, which browsers don't send to servers. Email security scanners that open links automatically therefore can't use it up. - Rate limits. At most 5 sign-in emails per address and 20 per network address in 15 minutes, and 5 wrong code attempts per code. The sign-in page responds the same way whether or not an account exists.
- Continue with Google, where it's offered, asks only for
openid email profile, uses PKCE, and only accepts email addresses Google has verified. It grants no access to your Google data. Accounts are keyed by email, so both methods reach the same account. - Sessions last 30 days in an HttpOnly cookie called
js_session. Each sign-in replaces the browser's previous session. Settings, Account has Sign out everywhere, which ends every session on every device.
To rate-limit sign-in requests and AI assistant registrations, Joinsight stores a keyed hash of the requesting network address alongside the sign-in token or the registered app. It doesn't store the address itself.
The app sends X-Frame-Options: DENY and frame-ancestors 'none' so its pages, including the MCP consent screen, can't be framed. It also sends nosniff, a strict-origin-when-cross-origin referrer policy, and a permissions policy that turns off camera, microphone and geolocation.
What the snippet stores about visitors
The snippet is written to collect as little as attribution needs. It doesn't set cookies.
Before consent
Nothing is written to the visitor's device and nothing is sent. On a landing page, the snippet works out the first touch (landing path, referrer, campaign tags) and keeps it in a JavaScript variable. If the visitor signs up before that page is left or reloaded, which in a single-page app includes signups after moving between its routes, identify() sends that first touch along with your user ID. A page reached from another page of your site isn't a landing, so a signup there sends no first touch at all, and the customer stays unattributed. Otherwise the touch disappears with the page.
After consent
Once you call joinsight("consent", true), or if the script tag has data-consent="granted", the snippet stores a random anonymous ID and sends one event per browser session, from the landing page. Consent given on a later page stores the ID but sends nothing for that session. The event contains:
- your site key and the anonymous ID,
- the landing path, without its query string,
- the referrer's hostname only,
- the five UTM parameters, each cut to 200 characters,
- which ad click parameter was present (
gclid,gbraid,wbraid,msclkidorfbclid), but not its value.
Joinsight adds the time of arrival. A returning visitor keeps their original first touch.
At signup
identify() sends your user ID, the visitor's email if you pass it, and either the anonymous ID or the in-memory first touch. The email is turned into a keyed hash on arrival and only used as a fallback match against billing records.
What is never stored
No IP addresses, no full referrer URLs, no page titles, no device fingerprints, and no profile across different Joinsight customers' sites. The ingest endpoints use the network address only in memory, to apply rate limits.
Customer data from billing sources
From Stripe and RevenueCat, Joinsight keeps what MRR and matching need: the customer ID, your user ID if you set one, the country, card expiry month and year, subscriptions and payments. Customer emails are stored only as a keyed hash plus a masked display form, so the dashboard shows j•••@e•••.com and never the full address. RevenueCat subscriber attributes are stripped from stored webhook events.
Because the email is hashed with a secret key, the same address produces the same hash, which is what lets a Stripe customer, a RevenueCat customer and an identify() call be recognised as one person. The hash can't be turned back into the address.
What Claude receives
Joinsight can use Anthropic's API to rewrite an alert's cause or a line of the Monday brief into one plain sentence. It is optional for the service: without it, the same sentences come from fixed templates.
The model receives a small set of facts and a draft sentence: formatted amounts, counts, percentages, page paths, search queries, channel and plan names. It never receives customer records, and any value that looks like an email address is removed before the request is sent. The reply is discarded in favour of the draft if it contains a number that wasn't in the facts, runs past 45 words, or breaks the house style.
The MCP server
When you connect Claude, ChatGPT or Cursor, the MCP server gives that assistant read-only tools.
- One workspace per connection, chosen on the consent screen. Connect again to add another.
- Customer rows can include the masked email, your user ID, country, plan, MRR and first touch. Full emails are never returned.
- Tokens are stored hashed. Access tokens last an hour and refresh tokens rotate on every use. If an old refresh token is presented again, Joinsight treats it as leaked and revokes the whole connection. Unused connections lapse after 30 days.
- Membership is checked on every call. If someone leaves the workspace, their connections stop working.
- Every tool call is logged with the tool name, its arguments, whether it succeeded and how long it took, whether it succeeds or not.
Once an assistant has received an answer, what happens to it is governed by your agreement with that assistant's provider. You can disconnect any app in Settings, AI assistants.
Disconnecting a source and deleting a workspace
Disconnecting a source deletes its stored credentials straight away. Joinsight stops reading from it at that moment. The data synced from it is removed by a cleanup job that runs once a day:
| Disconnected | What the cleanup deletes |
|---|---|
| Stripe or RevenueCat | That source's customers, subscriptions, billing periods and one-time payments, for Stripe the price-change notes on your charts, and for RevenueCat its stored webhook events. Customers left with no billing record are removed, and MRR is rebuilt |
| Search Console data and GA4 traffic | |
| Plausible | Plausible traffic |
| App Store Connect | App Store impressions, page views and downloads |
| Posts, their metrics, and the post notes on your charts, which quote the captions |
The data is usually gone within a day, and the privacy policy commits to deletion within 30 days of disconnecting. The same deletion runs, before the next import, when a connection switches to another source account: another Stripe account or mode, RevenueCat project, App Store app, Instagram account, Search Console or GA4 property, or Plausible site. Two accounts' data never mix. Disconnecting Google also revokes Joinsight's grant at Google. For other sources, delete the key on the source's side too: Joinsight can stop using a Stripe or RevenueCat key, but only you can revoke it.
Snippet data (visitors, sessions and identify() links) isn't tied to a source connection, so it stays until you delete the workspace.
Deleting a workspace, in Settings, Account, is immediate. It removes every connection, credential, synced number, snippet record, alert, brief and MCP log for that workspace, and it can't be undone. To delete your account itself, email hello@joinsight.app.
Sub-processors and GDPR roles
The privacy policy sets out two roles:
- Controller for data about you as a Joinsight user: your account, sign-ins, billing and support messages.
- Processor for data handled on your behalf: what Joinsight reads from your sources and what the snippet collects about your visitors. For that data, you are the controller.
The providers that process data for Joinsight are listed on the privacy page: a hosting provider for the app and database, Brevo for sign-in emails, the brief and alerts, Stripe for Joinsight's own billing, and Anthropic for the optional sentence rewriting. Google, Stripe, RevenueCat, Apple, Plausible and Meta also see Joinsight's requests when it reads from them on your behalf. Where a provider processes data outside the European Economic Area, the policy relies on an adequacy decision or the European Commission's standard contractual clauses.
What to tell your own visitors
Because you're the controller for visitor data, your privacy notice and your consent banner need to describe the snippet. What follows is a factual description of what it does, to copy from. It isn't legal advice: whether you need consent, and which legal basis applies, depends on your jurisdiction. The post on analytics, consent and the GDPR sets out the questions to take to an advisor.
What the snippet stores in the browser
| Key | Storage | Value | Written | Removed |
|---|---|---|---|---|
js_consent | localStorage | 1 | When consent is granted | On joinsight("consent", false), or when the visitor clears site data |
js_aid | localStorage | A random anonymous ID | When consent is granted | On joinsight("consent", false), or when the visitor clears site data |
js_s | sessionStorage | 1, marking that this session was counted | On the first event of a browser session | When the tab's session ends, or on joinsight("consent", false) |
localStorage entries have no expiry date: they last until the visitor clears them or withdraws consent. There are no cookies.
Wiring the consent API
Put the queue line from the Tracking snippet page before the script tag, so calls made before the script loads aren't lost. Then call the consent API from your banner:
// The visitor accepts analytics
joinsight("consent", true);
// The visitor refuses, or withdraws consent later
joinsight("consent", false);
Granting consent stores the keys above and, on a landing page, sends the session's one event. Refusing or withdrawing removes all three keys from the browser. It doesn't delete what was already sent; a visitor asking for that should contact you, and Joinsight will help you answer. Setting data-consent="granted" on the script tag starts tracking on page load without waiting for a banner, so only use it where consent isn't required or is collected some other way.
A description you can adapt
Joinsight (analytics, attribution). With your consent, we store a random identifier in your browser's local storage (
js_aid, withjs_consentrecording your choice, andjs_sin session storage for the current visit). Once per visit, it records the page you landed on, the website that referred you, and any campaign tags in the link. When you create an account, we link that first visit to your account so we can tell which pages and campaigns lead to sign-ups and payments. If you don't consent, nothing is stored in your browser; the page you arrived on and its referrer are only sent if you create an account before leaving that page. IP addresses are not stored. Joinsight processes this data on our behalf.
Adjust it to match how you've set things up. If you pass emails to identify(), or call it from your server, say so. If you use data-consent="granted", the sentence about consent doesn't apply.
Reporting a security issue
If you find a vulnerability or think your data has been exposed, email hello@joinsight.app. The privacy policy commits to telling you without undue delay if a breach affects your data.