Documentation

Build on Folio

Folio sits on top of PostHog or your existing event stream, finds users failing your goals, and returns approved copy + CTAs via a headless decisions API — banners, modals, and dashboard cards your app renders. This guide covers ingestion, the decisions API, and the operator workflow that turns events into approved copy your app renders.

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:

  1. Ingest — browser SDK, REST API, or PostHog webhook
  2. Goal — plain-English outcome with a target event
  3. Intelligence — cohorts + draft recommendations (rules or LLM)
  4. Approve — human-in-the-loop before anything goes live
  5. ServeGET /decisions/:surface returns copy + CTA
  6. 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:

  1. Sign up and complete onboarding (leave Origin URL blank for local dev).
  2. On your project page, click Prove it works — seeds events, creates a starter goal, and runs intelligence.
  3. Open Recommendations and approve a banner draft.
  4. 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.

Install
<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.

Track & identify
// After login, pin the user id
folio.identify("user_123", { plan: "trial" });

// Custom events
folio.track("pricing_viewed", { plan: "pro" });
Fetch a decision
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's phk_live_… key.
  • baseUrl — optional. Defaults to the script host + /api/v1.
  • mock — optional. Skips network calls when true.
  • 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.

Server-side track
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/.

Banner
import { FolioBanner } from "@/components/folio/folio-banner";

<FolioBanner
  publicKey="phk_live_…"
  userId={user.id}
  surface="banner"
/>
Modal
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.

Single event
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

Batch (max 100)
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 — success
  • 400 — invalid JSON or missing fields
  • 401 — unknown publicKey
  • 403 — origin not allowed (Origin URL mismatch)
  • 402 — trial expired or monthly limit reached
  • 413 — event batch over 100 rows

POST /identify

Merge traits onto an identity. Requires an active plan but does not count toward event limits.

Identify
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.

Decision
curl "https://folio-weld-seven.vercel.app/api/v1/decisions/banner?publicKey=phk_live_…&userId=user_123"

Response shape when a decision matches:

Response
{
  "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.

Feedback
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

  1. Open your project at /app/projects/[id].
  2. Scroll to Integrations and click Enable PostHog forwarding.
  3. Copy the two values Folio generates:
    • Webhook URL — includes your publicKey query param
    • Header X-Folio-Webhook-Secret — shared secret (starts with fwh_)

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 pipelineDestinations → add an HTTP Webhook destination (or customize an existing webhook template).

  1. URL — paste the Folio webhook URL exactly (includes ?publicKey=phk_live_…).
  2. MethodPOST.
  3. Headers — add a custom header:
    • Name: X-Folio-Webhook-Secret
    • Value: the secret from Folio (not your PostHog API key)
  4. Body— use PostHog's template syntax. The Integrations panel on your project page shows this JSON under "PostHog destination JSON body":
PostHog destination body (recommended)
{
  "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:

  1. Resolves publicKey from the URL and verifies X-Folio-Webhook-Secretagainst the project's stored secret.
  2. Parses the JSON body into one or more normalized events (see supported shapes below).
  3. Checks billing — forwarded rows count toward your monthly events meter the same as SDK track() calls.
  4. Inserts into the events table, updates identity last_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)

Single wrapped event
{
  "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

Flat event
{
  "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.

Batch
[
  { "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's url column when present.
  • String, number, and boolean custom properties are kept.
  • If PostHog sends properties as 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

Request
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": "…", … } }
Success response
{ "ok": true, "accepted": 1 }

Webhook HTTP status codes

  • 200 — event(s) accepted and stored
  • 400 — missing publicKey param, invalid JSON, or no parseable events in body
  • 401 — unknown publicKey, or wrong X-Folio-Webhook-Secret
  • 403 — PostHog forwarding not enabled for this project (click Enable in Integrations first)
  • 402 — trial expired, monthly event limit reached, or plan below Pro
  • 500 — database insert failure (retry from PostHog)

Verify it's working

  1. Trigger an event in PostHog (or use PostHog's destination test/debug mode).
  2. On your Folio project page, check Recent events — forwarded events appear within seconds.
  3. Confirm properties.source is posthog in the event row.
  4. 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 /feedback with action: "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 missing event + 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 402 from Folio means billing limits or plan tier — check Settings → Billing.

Preview returns nothing

  • Confirm the user ID matches a distinct_id in your events and is materialized into a cohort (check Cohorts → member count).
  • Ensure at least one recommendation is approved for 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.