Dialogue Tree Writer
Lovable AI runs an agent negotiating branching narrative fees via Dodo so studios hire interactive fiction writers.
The kernel.
Game designers tap Buy for interactive fiction writing, land on a Dodo-hosted checkout, and the webhook flips the unlock the moment the payment clears — no card fields, no PCI scope, no waiting.
Why this primitiveDodo Checkout hosts the card fields and 3DS flow, so the app collects money for interactive fiction writing without PCI scope — one server call, one redirect, one webhook.
Required keys.
Add this in your Lovable project under Settings → Secrets before pasting the prompt below.
The build prompt.
Paste into a fresh Lovable project. Make sure the key above is set first. read the build strategy →
Build "Dialogue Tree Writer" as a ONE-SHOT Lovable build. The participant has only
5 credits — this single message must produce a working demo with no follow-ups.
Single-page TanStack Start app. Cut scope ruthlessly.
CONCEPT
Lovable AI runs an agent negotiating branching narrative fees via Dodo so studios hire interactive fiction writers.
Discipline: Game Design & Interactive Media (interactive fiction writing).
Recipe: Dodo Checkout as the single Dodo Payments surface.
Kernel: hosted one-time Dodo checkout that mints a payment link for the deliverable
UI: one Buy button that redirects to the Dodo-hosted checkout and unlocks on webhook
CRITICAL — HOW THE RETURN PAGE LEARNS THE OUTCOME
Do NOT gate the return page on a cookie set by the webhook. The webhook is a
server-to-server request from Dodo to your endpoint, so any `Set-Cookie` it
emits goes back to Dodo's server, not the buyer's browser — the return page
would hang on "Waiting for confirmation…" forever. Instead, verify status on
the return page by calling `dodo.payments.retrieve(payment_id)` (or
`dodo.subscriptions.retrieve(subscription_id)`) with the id Dodo appends to
the `return_url` query string. Webhooks are for durable side effects only
(logs, emails, DB writes) — never for unlocking the UI.
LOVABLE BUDGET (HARD CAP: ONE-SHOT, ~5 CREDITS TOTAL):
- ONE TanStack Start app, ONE public route + ONE return route + ONE webhook route.
- TanStack server functions for `startCheckout` and `verifyPayment`.
- NO database, NO auth, NO Lovable Cloud, NO extra integrations.
- NO tests, NO settings screens, NO theming toggles.
- Libraries: template defaults + `zod` + `dodopayments` + `standardwebhooks`. Nothing else.
- Demo-fallback contract: every code path must render a helpful empty state
when `DODO_API_KEY` is missing, so the app boots without secrets.
STACK
- TanStack Start app, one product/marketing route + `/thank-you` return route + `/api/public/dodo-webhook`.
- Payments: Dodo Payments Node SDK, called from `createServerFn` handlers
so the API key stays server-side.
- Webhook: `/api/public/dodo-webhook` verifies the Svix signature with
`standardwebhooks` and DODO_WEBHOOK_SECRET, then performs side effects only.
- Tailwind + shadcn. Editorial look: gold accent on a dark or warm-cream background,
generous type, one strong headline, one primary action.
- Footer renders: "Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14".
Install once: `bun add dodopayments standardwebhooks zod`.
SERVER FUNCTION (src/lib/checkout.functions.ts) — Dodo hosted checkout:
```ts
import { createServerFn } from "@tanstack/react-start";
import { getRequest } from "@tanstack/start-server-core";
import { z } from "zod";
import DodoPayments from "dodopayments";
/** Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14 */
export const startCheckout = createServerFn({ method: "POST" })
.inputValidator((d) => z.object({ productId: z.string().min(1) }).parse(d))
.handler(async ({ data }) => {
const key = process.env.DODO_API_KEY;
if (!key) throw new Error("DODO_API_KEY not set");
const dodo = new DodoPayments({
bearerToken: key,
environment: process.env.DODO_MODE === "live" ? "live_mode" : "test_mode",
});
const origin = new URL(getRequest().url).origin;
const payment = await dodo.payments.create({
payment_link: true,
billing: { city: "-", country: "US", state: "-", street: "-", zipcode: "-" },
customer: { email: "buyer@example.com", name: "Buyer" },
product_cart: [{ product_id: data.productId, quantity: 1 }],
return_url: `${origin}/thank-you`,
});
return { payment_id: payment.payment_id, payment_link: payment.payment_link };
});
```
WEBHOOK (src/routes/api/public/dodo-webhook.ts) — verify signature, side effects only:
```ts
import { createFileRoute } from "@tanstack/react-router";
import { Webhook } from "standardwebhooks";
export const Route = createFileRoute("/api/public/dodo-webhook")({
server: { handlers: { POST: async ({ request }) => {
const secret = process.env.DODO_WEBHOOK_SECRET;
if (!secret) return new Response("no secret", { status: 503 });
const body = await request.text();
let event: { type?: string; data?: { payment_id?: string } };
try {
event = new Webhook(secret).verify(body, {
"webhook-id": request.headers.get("webhook-id")!,
"webhook-timestamp": request.headers.get("webhook-timestamp")!,
"webhook-signature": request.headers.get("webhook-signature")!,
}) as typeof event;
} catch { return new Response("invalid signature", { status: 401 }); }
if (event.type === "payment.succeeded") {
// Side effects only — log, email, DB write. Do NOT Set-Cookie: the buyer's
// browser never sees this response. The return page verifies via retrieve().
console.log("paid:", event.data?.payment_id);
}
return new Response("ok");
}}},
});
```
RETURN PAGE (src/routes/thank-you.tsx) — verify via `dodo.payments.retrieve`:
```ts
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { useSuspenseQuery } from "@tanstack/react-query";
import { useState } from "react";
import { z } from "zod";
import DodoPayments from "dodopayments";
export const verifyPayment = createServerFn({ method: "GET" })
.inputValidator((d) => z.object({ paymentId: z.string().nullable() }).parse(d ?? { paymentId: null }))
.handler(async ({ data }) => {
if (!data.paymentId) return { status: "missing" as const };
const key = process.env.DODO_API_KEY;
if (!key) return { status: "missing" as const };
const dodo = new DodoPayments({
bearerToken: key,
environment: process.env.DODO_MODE === "live" ? "live_mode" : "test_mode",
});
const p = await dodo.payments.retrieve(data.paymentId);
const raw = String(p.status ?? "").toLowerCase();
const status = ["succeeded","success","paid","completed"].includes(raw) ? "succeeded"
: ["failed","cancelled","canceled","expired"].includes(raw) ? "failed"
: ["pending","processing","requires_action","requires_payment_method"].includes(raw) ? "pending"
: "unknown";
return { status, paymentId: data.paymentId };
});
export const Route = createFileRoute("/thank-you")({
validateSearch: (s: Record<string, unknown>) => ({
payment_id: typeof s.payment_id === "string" ? s.payment_id : undefined,
}),
loaderDeps: ({ search }) => ({ paymentId: search.payment_id ?? null }),
loader: ({ deps }) => verifyPayment({ data: { paymentId: deps.paymentId } }),
component: ThankYou,
});
function ThankYou() {
const initial = Route.useLoaderData();
const { paymentId } = Route.useLoaderDeps();
const [tick, setTick] = useState(0);
const { data } = useSuspenseQuery({
queryKey: ["dodo", paymentId, tick],
queryFn: () => verifyPayment({ data: { paymentId } }),
initialData: initial,
refetchInterval: (q) => (q.state.data as { status?: string })?.status === "pending" ? 3000 : false,
});
if (data.status === "succeeded") return <div>Unlocked — deliverable revealed.</div>;
if (data.status === "failed") return <div>Payment failed.</div>;
if (data.status === "missing") return <div>No payment id — start checkout first.</div>;
return <div>Confirming payment… <button onClick={() => setTick(t => t + 1)}>Retry</button></div>;
}
```
CLIENT (src/routes/index.tsx): one Buy button that calls `startCheckout` via
`useServerFn`, then redirects `window.location.href = payment_link`. Dodo
returns the buyer to `/thank-you?payment_id=…`; that route verifies via
`dodo.payments.retrieve()` and reveals the deliverable.
USER FLOW (the entire app — nothing else exists)
1. Land on the page; the headline previews what the product does.
2. Tap the primary action; a TanStack server function calls Dodo.
3. Redirect to the Dodo-hosted checkout; buyer pays in their preferred currency/method.
4. Return to `/thank-you?payment_id=…` (Dodo appends the id automatically);
the return page calls `dodo.payments.retrieve()` server-side to verify
status and reveal the deliverable. NEVER rely on a webhook cookie —
webhooks are server-to-server and their `Set-Cookie` never reaches the
buyer's browser.
KEYS (Dodo Payments — both required for the live path):
1. `DODO_API_KEY` — Dodo API key from the dashboard (Developer → API Keys).
Test mode by default; set `DODO_MODE=live` to charge real cards.
2. `DODO_WEBHOOK_SECRET` — Signing secret from Dodo → Webhooks → Endpoints →
New Endpoint. Starts with `whsec_`. The webhook route verifies every event
with this via the `standardwebhooks` library.
The demo MUST still boot with zero secrets and render a helpful empty state
so participants can see the shell before wiring up Dodo.
CREDIT (must appear in UI footer AND as JSDoc on the server function):
Built during the Creative AI & Quantum Hackathon organised by StreetKode Fam during Indian Krump Festival 14
Market sizing.
Indicative figures for hackathon pitches — refine with your own research before raising.