Without PostHog or Segment, two pieces connect your app:
- A small script on every page a signed-in user sees. It tells PennyPost who the user is and which pages they open.
- One event from your server,
results_ready, the first time a user's results are ready.
Let your coding tool do it
On Activation, open Other ways to connect and choose Let Claude Code or Cursor add it. Copy the prompt and paste it into Claude Code or Cursor in your app's repo. It adds the script, signs the user's email on your server, sends the event and writes a test.
The prompt holds public IDs only. Add these two secrets to your server's environment yourself, then redeploy:
PENNYPOST_API_KEY, from Make a key or API keysPENNYPOST_IDENTITY_SECRET, shown on the same screen
Keep them out of the AI chat and out of anything starting with NEXT_PUBLIC_ or VITE_.
With the MCP server connected, you can also just ask your coding tool to "connect this app to PennyPost".
Add it by hand
The script
Put this in the layout every signed-in page uses, with your site ID:
<script>window.pennypost=window.pennypost||function(){(pennypost.q=pennypost.q||[]).push(arguments)}</script>
<script async src="https://usepennypost.com/snippet.js" data-site="YOUR_SITE_ID"></script>
Then, once per page load, identify the user:
pennypost("identify", { email, signature });
signature is computed on your server: HMAC-SHA256 of the email, trimmed and lowercased, with your identity secret as the key, as lowercase hex. It stops anyone from sending page views in someone else's name.
import crypto from "node:crypto";
const signature = crypto
.createHmac("sha256", process.env.PENNYPOST_IDENTITY_SECRET)
.update(email.trim().toLowerCase())
.digest("hex");
On sign-out, call pennypost("reset").
The script sends the path of each page (never the query string or anything after #), including page changes in single-page apps. It sends nothing for visitors who are never identified. You can also mark an action yourself with pennypost("track", "export_clicked").
If your app has a Content-Security-Policy, add https://usepennypost.com to script-src and connect-src.
Consent
If you ask EU or UK visitors for consent, add data-consent="required" to the script tag. Until you call pennypost("consent", true), the script sends and stores nothing. pennypost("consent", false) drops anything waiting and forgets the person. Call it with the banner's answer on every page load.
The results event
From your server, the first time a user's results are ready (their first finished render, scan, generation or import), send:
curl -X POST https://usepennypost.com/api/v1/events \
-H "Authorization: Bearer $PENNYPOST_API_KEY" \
-H "Content-Type: application/json" \
-d '{"app": "your-app", "email": "jane@example.com", "event": "results_ready", "createContact": true,
"properties": {"url": "https://yourapp.com/projects/42", "headline": "Your 12 headshots are ready."}}'
urlis the page where they see these results. The email links straight to it, as long as it's on your domain.headlineis one sentence with their own numbers. The "left before their results were ready" email can use it as{{event.headline}}.
Use a short timeout (10 seconds) and log failures. PennyPost should never slow down or break the job that made the results.
Check it works
After you deploy, open your app in another tab. Activation shows "Waiting for the first event..." until one arrives. If events come in without an email, you'll see "Call identify with the user's email after they sign in."
Rotating the secret
Rotate on API keys makes a new identity secret. The old one keeps working for 24 hours so you can deploy the new one.