Foley Asset Buyer
Searches and buys bespoke foley sound effects via Dodo Payments so audio mixers finalize soundscapes faster.
The kernel.
Videographers tap Buy for sound design assets, 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 sound design assets 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 "Foley Asset Buyer" 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
Searches and buys bespoke foley sound effects via Dodo Payments so audio mixers finalize soundscapes faster.
Discipline: Videography & Film (sound design assets).
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.