Connect identity and required independent capabilities before publishing a Funnel and pinned references. Funnel orchestrates them; cross-day waits/background execution need their matching runtime and cannot be inferred from graph nodes.
Funnel integration & recovery
Explicitly start a versioned flow and handle identity, retries and results.
SDK examples target the current API. Check matching SDK versions and HTTP alternatives
Before you start
Design and explicitly start a Funnel
Funnel composes independent capabilities into a versioned flow. It owns graph execution and accepted results; Offering, Paywall, Coupon, commerce and GrowthBook retain their own definitions and business facts.
- Create a flow under Funnels, connecting Page/Popup screens, conditions, experiments and supported capability nodes. Pin a published Paywall Version when referenced; publication fixes the graph and resource references.
- Start explicitly through SDK/API. A matching backend/SDK can use campaign_decide to retain a campaign decision and campaign_start to enforce a shown-based cooldown; ordinary decide/start do not enable these controls automatically. Verify host login before issuing the current Customer session. Create a Guest and Guest session for an unsigned visitor.
- Retain requestId and the complete start input, replaying them unchanged after a lost response. SDK installation requirements are linked in the page version notice.
import { createFunnelStartClient, createIndianaServer, createGuestToken,} from "@tinkerlandai/indiana-sdk/server"; const options = { baseUrl: process.env.INDIANA_BASE_URL!, apiKey: process.env.INDIANA_SECRET_KEY!, scope: { projectId: process.env.INDIANA_PROJECT_ID!, environment: "sandbox" },} as const;const indiana = createIndianaServer(options);const funnels = createFunnelStartClient(options); export async function POST(request: Request) { if (request.headers.get("origin") !== process.env.APP_ORIGIN) return new Response("Forbidden", { status: 403 }); const session = await requireHostSession(request); // Every run has a Customer. A signed-in user is named by your own user ID: Indiana creates the // Customer on first use with creation-only customerData (needs customers:write). A visitor is a // Guest kept in this session, referenced by its Indiana Customer ID. let customer; if (session.userId) { customer = { type: "externalUserId", value: session.userId } as const; } else { session.indianaGuestToken ??= createGuestToken(); await session.save(); const guest = await indiana.createGuestCustomer({ guestToken: session.indianaGuestToken }); customer = { type: "id", value: guest.customerId } as const; } // Persist before the first request. Retries reuse the whole original input. if (!session.funnelStart) { session.funnelStart = { funnelId: process.env.INDIANA_FUNNEL_ID!, requestId: crypto.randomUUID(), customer, ...(session.userId ? { customerData: { attributes: { plan: session.plan } } } : {}), locale: session.locale, parameters: { commerce: { provider: "stripe", currency: "USD" } }, }; await session.save(); } const result = await funnels.start(session.funnelStart); // The browser runs the Funnel as that Customer. const { customerToken } = session.userId ? await indiana.createCustomerSession(result.customerId!) : await indiana.resumeGuestSession({ guestToken: session.indianaGuestToken! }); session.indianaRunToken = result.runToken; await session.save(); return Response.json({ runToken: result.runToken, customerToken }, { headers: { "Cache-Control": "private, no-store" }, });}Display, retry and identity changes
requireHostSession, session.save, reportError and purchase callbacks are host integration points. Commercial effects still call independent services. Show an explicit error for an unsupported action rather than marking it complete in the browser.
To use a claimed Coupon, pass resolveOfferId({ actionId, productVersionId }) to showFunnelRun or showPaywall and return the selected Coupon Grant UUID, or undefined for regular terms. The SDK selects it before quoting and retains it across retries of that request. Claiming a Coupon does not automatically apply a discount; the server still validates ownership, eligibility, expiry and Product scope. Custom interfaces pass offerId explicitly to session.preview.
- destroy only cleans up UI; close submits a close command. On navigation or identity change, clear UI, requests and in-memory credentials so late results cannot overwrite the new identity.
- An in-place Guest claim keeps its UUID and may continue through link. If claimGuestCustomer returns switched=true, close the Guest view, create a session for the returned Customer and start another flow. Histories and holdings are not automatically merged.
- Local/Remote is configured per Funnel and changed separately by Owner/Admin. Accepted results remain fixed; new requests use current rules. Downloaded Local rules cannot be recalled.
- Funnel completion, clicks, redirects and custom receipts do not prove payment or access. Verify commercial operation state and readHoldings.
Related:Offering·Paywall·Version compatibility
import { createFunnelRunController, showFunnelRun } from "@tinkerlandai/indiana-sdk"; // Your backend retains the original start request and run credential.export async function openFunnel(container: HTMLElement) {const response = await fetch("/api/indiana-funnel", { method: "POST", credentials: "same-origin", cache: "no-store",});if (!response.ok) throw new Error("Funnel bootstrap failed");const { runToken, customerToken } = await response.json();const client = createFunnelRunController({ baseUrl: "https://your-indiana-host.example", scope: { projectId: "your-app-uuid", environment: "sandbox" }, runToken,});client.identify(customerToken);const handle = await showFunnelRun(container, { client, // Record-only purchase: your backend creates the Stripe Checkout Session from execution. onPurchase: ({ operation }) => startMerchantCheckout(operation.execution, operation.operationId), // External pages: without onOpenUrl the SDK opens this window or a new one as authored (openIn). onError: reportError,});// Keep the host's original bootstrap across navigation and payment returns.// Destroy removes UI; close is an explicit server command.return { handle, client, destroy() { handle.destroy(); client.reset(); } };}// On route leave: flow.destroy(). On explicit dismissal: await flow.client.submit({ operation: "close", requestId: crypto.randomUUID(), nodeId: flow.client.view!.nodeId, visit: flow.client.view!.visit, revision: flow.client.view!.revision });Check your result
Expected outcome
Retain the pinned Version, run credential and original commands. Distinguish waiting, closed, failed and completed; recovery must not repeat accepted commercial operations.