Overview
Folio sits on top of your existing event stream. PostHog (or your analytics tool) tells you what happened; Folio tells your app what to show next — approved copy and CTAs for users who are failing a goal you define.
The loop looks like this:
- Ingest — browser SDK, REST API, or PostHog webhook
- Goal — plain-English outcome with a target event
- Intelligence — cohorts + draft recommendations (rules or LLM)
- Approve — human-in-the-loop before anything goes live
- Serve —
GET /decisions/:surfacereturns copy + CTA - Prove — serve → click → convert attribution per recommendation
Shipped surfaces today: banner, modal, and dashboard card. Your app handles layout; Folio returns the decision payload.
Concepts
Project & public key
A project is one product or app. Each project has a phk_live_… public key safe to embed in browser code. Find it on the project page after signup.
Events & identities
track() records behavioral events. identify() attaches traits to a stable distinct_id. Cohort rules evaluate events and traits together.
Goals
A goal describes an outcome — e.g. "get trial users to complete signup" — with an optional target event used for cohort rules and conversion attribution.
Cohorts & recommendations
Running intelligence proposes cohorts (users at risk of missing the goal) and draft recommendations (copy per surface). You edit caps and holdout %, then approve drafts before they can be served.
Decisions
A decision is the copy + CTA returned for a specific user and surface. Each successful serve is logged for attribution and counts toward your monthly decision limit. Holdout impressions and empty responses are not billed as serves.
Quick start
Fastest path to a working decision — no external deploy required:
- Sign up and complete onboarding (leave Origin URL blank for local dev).
- On your project page, click Prove it works — seeds events, creates a starter goal, and runs intelligence.
- Open Recommendations and approve a banner draft.
- Use Preview decision with user ID
folio_demo_user— you should see copy.
For production, install the snippet below and use the same distinct_id in preview that your SDK sends.
<script src="https://folio-weld-seven.vercel.app/folio.js" defer></script>
<script>
window.folioConfig = { publicKey: "phk_live_…" };
</script>Browser SDK
public/folio.js exposes window.folio. It auto-captures$pageview on load and on SPA navigations.
// After login, pin the user id
folio.identify("user_123", { plan: "trial" });
// Custom events
folio.track("pricing_viewed", { plan: "pro" });const decision = await folio.getDecision({
surface: "banner",
userId: "user_123",
});
if (decision) {
renderBanner({
title: decision.title,
body: decision.body,
cta: { label: decision.ctaLabel, href: decision.ctaUrl },
});
// On dismiss or CTA click:
folio.feedback({
logId: decision.logId,
action: "clicked", // or "dismissed"
});
}folioConfig reference
publicKey— required. Your project'sphk_live_…key.baseUrl— optional. Defaults to the script host +/api/v1.mock— optional. Skips network calls whentrue.mockDecisions— optional. Fixture map keyed by surface for mock mode.
Auto pageviews
Unless mock is on, the SDK fires $pageview on load and on SPA navigations (pushState, replaceState, popstate). Session IDs reset after 30 minutes idle.
Mock mode (UI only)
Pass mock: true and mockDecisions in folioConfig to render UI without network calls. Useful for Storybook — not a substitute for end-to-end testing.
Node SDK
For server-side event ingestion (cron jobs, backend handlers), use FolioClient from lib/folio/node.ts in this repo. Pass an explicit baseUrl when not calling the same host.
import { FolioClient } from "@/lib/folio/node";
const folio = new FolioClient({
publicKey: "phk_live_…",
baseUrl: "https://folio-weld-seven.vercel.app/api/v1",
});
await folio.track("user_123", "invoice_paid", { amount_cents: 9900 });
await folio.identify("user_123", { plan: "pro" });Decisions are usually fetched client-side or from your API layer calling GET /decisions/:surface. For Next.js Server Components use getServerDecision().
React
Drop-in components live in the repo under components/folio/.
import { FolioBanner } from "@/components/folio/folio-banner";
<FolioBanner
publicKey="phk_live_…"
userId={user.id}
surface="banner"
/>import { FolioModal } from "@/components/folio/folio-modal";
<FolioModal publicKey="phk_live_…" userId={user.id} />For Server Components, use getServerDecision() from lib/folio/server-decision.ts with the same public key and user id.
REST API
Base URL: https://folio-weld-seven.vercel.app/api/v1. All browser endpoints respect CORS. If your project has an Origin URL set, requests must come from that origin — leave it empty during local development.
POST /events
Track one event or a batch (max 100). Each event in a batch must share the same public key.
curl -X POST https://folio-weld-seven.vercel.app/api/v1/events \
-H "Content-Type: application/json" \
-d '{
"publicKey": "phk_live_…",
"distinctId": "user_123",
"name": "signup_completed",
"properties": { "plan": "pro" },
"url": "https://app.example.com/signup"
}'POST /events — batch
curl -X POST https://folio-weld-seven.vercel.app/api/v1/events \
-H "Content-Type: application/json" \
-d '[
{ "publicKey": "phk_live_…", "distinctId": "user_1", "name": "$pageview" },
{ "publicKey": "phk_live_…", "distinctId": "user_2", "name": "$pageview" }
]'HTTP status codes
200— success400— invalid JSON or missing fields401— unknownpublicKey403— origin not allowed (Origin URL mismatch)402— trial expired or monthly limit reached413— event batch over 100 rows
POST /identify
Merge traits onto an identity. Requires an active plan but does not count toward event limits.
curl -X POST https://folio-weld-seven.vercel.app/api/v1/identify \
-H "Content-Type: application/json" \
-d '{
"publicKey": "phk_live_…",
"distinctId": "user_123",
"traits": { "plan": "trial", "company_size": "10" }
}'GET /decisions/:surface
Surfaces: banner, modal, dashboard_card. Returns { decision: null } when nothing applies. Returns 402 when billing limits or trial expiry block serving.
curl "https://folio-weld-seven.vercel.app/api/v1/decisions/banner?publicKey=phk_live_…&userId=user_123"Response shape when a decision matches:
{
"decision": {
"id": "rec_uuid",
"logId": 42,
"surface": "banner",
"title": "You're close — one step left",
"body": "Most users who complete signup stick around.",
"ctaLabel": "Continue",
"ctaUrl": "/signup",
"priority": 0.8,
"reasoning": null
}
}POST /feedback
Record dismiss, click, or convert against a decision log entry.
curl -X POST https://folio-weld-seven.vercel.app/api/v1/feedback \
-H "Content-Type: application/json" \
-d '{
"publicKey": "phk_live_…",
"distinctId": "user_123",
"logId": 42,
"action": "clicked"
}'PostHog
Keep PostHog for product analytics and funnel exploration. Forward a copy of each event into Folio — included on every plan — so cohorts, intelligence, and decisions run on the same behavioral stream, without replacing PostHog.
Folio owns goals, cohorts, approvals, and the decisions API. PostHog is the ingestion pipe, not the decision brain.
Prerequisites
- Plan: PostHog forwarding is included on every plan, trial included.
- Folio project: You need your
phk_live_…public key (Project page → Install the SDK). - PostHog project: Admin access to create a Data pipeline destination.
Step 1 — Enable in Folio
- Open your project at
/app/projects/[id]. - Scroll to Integrations and click Enable PostHog forwarding.
- Copy the two values Folio generates:
- Webhook URL — includes your
publicKeyquery param - Header X-Folio-Webhook-Secret — shared secret (starts with
fwh_)
- Webhook URL — includes your
Use Rotate secret if the secret is ever exposed. Update the header in PostHog immediately after rotating — old secrets stop working instantly.
Step 2 — Create a PostHog destination
In PostHog: Data pipeline → Destinations → add an HTTP Webhook destination (or customize an existing webhook template).
- URL — paste the Folio webhook URL exactly (includes
?publicKey=phk_live_…). - Method —
POST. - Headers — add a custom header:
- Name:
X-Folio-Webhook-Secret - Value: the secret from Folio (not your PostHog API key)
- Name:
- Body— use PostHog's template syntax. The Integrations panel on your project page shows this JSON under "PostHog destination JSON body":
{
"event": {
"event": "{event.event}",
"distinct_id": "{event.distinct_id}",
"timestamp": "{event.timestamp}",
"properties": "{event.properties}"
}
}PostHog substitutes {event.event}, {event.distinct_id}, etc. at send time. This wrapped shape matches PostHog's CDP destination format and is what Folio parses by default.
Optional: add destination filters in PostHog to forward only specific events (e.g. exclude $feature_flag_called) and reduce Folio event usage.
What Folio does with each webhook
When PostHog POSTs to Folio, the server:
- Resolves
publicKeyfrom the URL and verifiesX-Folio-Webhook-Secretagainst the project's stored secret. - Parses the JSON body into one or more normalized events (see supported shapes below).
- Checks billing — forwarded rows count toward your monthly events meter the same as SDK
track()calls. - Inserts into the
eventstable, updates identitylast_seen_at, and runs goal conversion attribution when the event name matches a goal target.
Webhook requests are server-to-server — they do notgo through browser CORS or your project's Origin URL allowlist. That only applies to SDK and REST calls from the browser.
Supported payload shapes
Folio accepts any of these JSON bodies (verified by the ingestion parser):
Wrapped CDP (recommended — matches template above)
{
"event": {
"event": "$pageview",
"distinct_id": "user_abc",
"timestamp": "2025-06-01T12:00:00.000Z",
"properties": { "$current_url": "https://app.example.com/home", "plan": "trial" }
}
}Flat Folio-style
{
"distinct_id": "user_abc",
"event": "signup_completed",
"timestamp": "2025-06-01T12:00:00.000Z",
"properties": { "plan": "pro" }
}Batch array
An array of flat or wrapped events in one request. Useful if you customize the PostHog Hog function to batch sends.
[
{ "distinct_id": "user_1", "event": "trial_started" },
{ "event": { "event": "$pageview", "distinct_id": "user_2", "timestamp": "…" } }
]Property handling
- PostHog system properties prefixed with
$(e.g.$lib,$device_type) are stripped before storage — except$current_url, which is copied to the event'surlcolumn when present. - String, number, and boolean custom properties are kept.
- If PostHog sends
propertiesas a JSON string (common with template substitution), Folio parses it automatically. - Every ingested PostHog event gets
properties.source = "posthog"so you can distinguish forwarded traffic in cohort rules.
Webhook endpoint reference
POST https://folio-weld-seven.vercel.app/api/v1/webhooks/posthog?publicKey=phk_live_…
Content-Type: application/json
X-Folio-Webhook-Secret: fwh_…
{ "event": { "event": "…", "distinct_id": "…", … } }{ "ok": true, "accepted": 1 }Webhook HTTP status codes
200— event(s) accepted and stored400— missingpublicKeyparam, invalid JSON, or no parseable events in body401— unknownpublicKey, or wrongX-Folio-Webhook-Secret403— PostHog forwarding not enabled for this project (click Enable in Integrations first)402— trial expired, monthly event limit reached, or plan below Pro500— database insert failure (retry from PostHog)
Verify it's working
- Trigger an event in PostHog (or use PostHog's destination test/debug mode).
- On your Folio project page, check Recent events — forwarded events appear within seconds.
- Confirm
properties.sourceisposthogin the event row. - Run intelligence (or wait for cron) so cohorts materialize, then approve a recommendation and preview a decision for that
distinct_id.
PostHog destinations run asynchronously with automatic retries — a transient Folio 500does not block PostHog's own event capture.
Advanced: Hog function alternative
If you need more control (custom filters, batching, or dynamic headers), edit the PostHog destination source code and use Hog's fetch() to POST the same URL and header. The body must still match one of the supported shapes above.
Operator console
Everything after ingestion happens in the dashboard at /app.
Goals
Create a goal with a clear target event. The intelligence engine uses it for cohort rules and conversion tracking.
Run intelligence
On the project page, click Run intelligence. This proposes cohorts, materializes members, and drafts recommendations. Without an Anthropic API key, rule-based fallback runs automatically.
Approve recommendations
Edit drafts on the Recommendations page — set cooldown days, max serves per user, holdout %, and suppression rules — then approve. Only approved recommendations are served.
Recommendation settings
Before approving, you can tune each draft:
- Cooldown days — window for frequency-cap checks
- Max serves per user — cap impressions in that window
- Holdout % — deterministic control group (logged, not billed as serves)
- Suppress if dismissed / converted — stop re-showing after user action
Pipeline checklist
Each project page shows a five-step checklist. When all steps are green, preview and live serving should work for users materialized into cohorts.
Attribution
Every billed serve writes a row to decision_log with a logId.
- clicked — user tapped the CTA (
POST /feedbackwithaction: "clicked") - dismissed — user closed the surface
- converted— user fired the goal's target event after a serve (automatic via
attributeGoalConversion)
Per-recommendation stats appear on the Recommendations page. Project overview shows 7-day serve / click / convert rollups.
Billing & limits
Folio meters three things: events ingested, decisions served, and intelligence runs. See pricing for tier details.
- Events — each
track()call or PostHog row ingested - Decisions — each successful serve that returns copy (holdouts excluded)
- Intelligence runs — each manual or cron intelligence pass
View usage at Settings → Billing. Trial lasts 14 days; expired trials return 402 on ingest and decision endpoints.
Troubleshooting
PostHog webhook returns 401 or 403
403 PostHog webhook not enabled— click Enable PostHog forwarding in Project → Integrations before PostHog sends traffic.401 Invalid webhook secret— header value must match Folio exactly (case-sensitive). Re-copy after rotating.401 Unknown publicKey— webhook URL must include the correct?publicKey=phk_live_…from your project page.
PostHog webhook returns 400
No events parsed from payload— body is missingevent+distinct_id. Use the destination JSON template from Integrations.- Confirm PostHog substituted template variables — raw
{event.event}strings in the body mean templating wasn't applied.
PostHog events missing in Folio
- Check PostHog destination logs for failed deliveries and retry status.
- Destination filters may exclude the events you expect — widen filters or test with
$pageview. - A
402from Folio means billing limits or plan tier — check Settings → Billing.
Preview returns nothing
- Confirm the user ID matches a
distinct_idin your events and is materialized into a cohort (check Cohorts → member count). - Ensure at least one recommendation is
approvedfor that surface. - Check suppression — user may have dismissed, converted, or hit frequency caps.
SDK requests fail with 403
Your project Origin URL may not match localhost. Clear it on the project settings or set it to your dev origin exactly.
402 Payment Required
Trial expired or monthly limit reached. Upgrade via pricing or check usage in billing settings.
0 members after intelligence
Cohort rules need matching events. Use Send test events or fire real traffic, then re-run intelligence. Goals with a target event produce better cohorts.
Still stuck?
Use Prove it works on the project page for a known-good path, then compare your live distinct_id to the preview field.