Art Fair Booth Buyer
Discovers available fair booths and triggers Dodo hosted checkouts so gallerists secure prime space.
Dodo Checkout· hosted one-time payment
Section · Agentic
full primer →The kernel.
Painters tap Buy for exhibition logistics, 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 exhibition logistics without PCI scope — one server call, one redirect, one webhook.
Kernel
a Dodo `POST /payments` call that mints a one-time hosted checkout link for a digital deliverable, then verifies the resulting `payment.succeeded` webhook (Svix-signed HMAC-SHA256) before unlocking the goods
Drives the UI as
a single Buy button that redirects to the Dodo-hosted checkout and, on return, reads a webhook-set cookie to reveal the download
Required keys.
DODO_API_KEY
Your Dodo Payments API key (test mode by default). Create one in the Dodo dashboard under Developer → API Keys.
open ↗DODO_WEBHOOK_SECRET
Signing secret from Dodo → Webhooks → Endpoints → New Endpoint. Starts with whsec_. The webhook route verifies every request with it before unlocking the download.
open ↗Add this in your Lovable project under Settings → Secrets before pasting the prompt below.
Appendix · Mega-prompt
The build prompt.
budget · 1 message
Paste into a fresh Lovable project. Make sure the key above is set first. read the build strategy →
Build "Art Fair Booth 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
Discovers available fair booths and triggers Dodo hosted checkouts so gallerists secure prime space.
Discipline: Visual Art (exhibition logistics).
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.
TAM
$5B
global art fair and exhibition market
SAM
$1B
gallery booth rental fees
SOM
$100M
mid-tier contemporary galleries
Indicative figures for hackathon pitches — refine with your own research before raising.
Adjacent entries.
custom portraiture
Portrait Commission Broker
Discovers portraitists and executes Dodo hosted checkouts so collectors secure verified commissions.
commercial licensingIllustration Rights Licensor
Discovers stock portfolios and finalizes Dodo hosted checkouts so agencies secure commercial print rights.
studio procurementCanvas Supply Auto-Buyer
Monitors inventory and executes Dodo hosted checkouts so painters automatically restock bulk materials.
presentation logisticsCustom Framing Broker
Negotiates framing specs and completes Dodo hosted checkouts so artists receive gallery-ready pieces.