# Build a custom frontend integrated with StubJoy Integrate the existing website with StubJoy's public backend API, covering the complete hosted microsite experience while preserving this website's design and framework. ## Configuration and authoritative references Replace these values before implementation: - API_BASE_URL: https://api.stubjoy.com - PREVIEW_SLUG: - EVENT_ID: - FRONTEND_ORIGIN: - EVENT_TIME_ZONE: - LOCALE: - ORG_SLUG: Read the interactive Scalar API reference at https://api.stubjoy.com/docs and its machine-readable contract at https://api.stubjoy.com/openapi.json before implementing. This prompt is also available at https://api.stubjoy.com/docs/integration-prompt. Use the deployed contract for exact field types, required properties, examples, and error responses. Do not invent endpoints or assume access to our repository. The operator must provide the API origin and event/slug, publish the microsite when using site resolution, and configure online ticket variants and the organization's Stripe account. Use a test event connected to Stripe test mode for end-to-end payment tests. All documented endpoints are public and need no API key, admin login, cookies, or Stripe secret. Never use /rpc, /api/auth, or backend webhook routes for visitor features. Keep all platform and Stripe secrets off this frontend. ## Connection setup Prefer your framework's server loaders/routes for content, checkout, and contact, forwarding requests to the API. This matches the hosted microsite. If calling the API directly from the browser, ask the operator to add FRONTEND_ORIGIN to the backend CORS_ORIGIN configuration; use credentials: "omit". For tracking, have the operator register the frontend hostname as an organization tracking domain, or confirm it is an active domain for this event or an allowed platform host. Tracking still requires a valid Origin header through a server proxy. Validate the incoming Origin against FRONTEND_ORIGIN before forwarding it; do not invent or trust arbitrary forwarded host headers. Registering a tracking domain does not grant CORS access to /events. The current server applies its global CORS preflight before tracking routes too: direct browser JSON requests to /track/* also require FRONTEND_ORIGIN in CORS_ORIGIN. A same-origin tracking proxy avoids browser-to-backend preflight, but the backend still checks the forwarded Origin. The /api/checkout, /api/contact and /api/track/* paths in the hosted microsite are frontend proxy routes, not endpoints on this backend. Create equivalents in your own application if using proxies. Keep proxy destinations fixed to API_BASE_URL, validate bodies and return URLs, preserve useful response statuses, and handle timeout/non-JSON failures. ## Site content and pages 1. Load GET /events/site/resolve?previewSlug=PREVIEW_SLUG. Alternatively resolve by hostname only if the operator has configured that hostname as an active hosted microsite domain. A published preview slug works for a separately hosted frontend without changing that frontend's DNS. If both parameters are present, hostname wins. Missing/unpublished sites return 404. 2. The response includes site, event, nullable location, features, sponsors, faqs, policies, occurrences, and ticketVariants. GET /events/{eventId} is available for event data alone but omits site settings/gallery. There is no public event-list endpoint. 3. Build the event home page with title, description, header image/logo, category, date/schedule summary, venue address and map/directions link, feature cards/icons, sponsor logos/links grouped by tier, FAQs, gallery with alt text, ticket CTA, contact link, and policy links. Preserve the returned ordering for FAQs, sponsors, gallery, and variants. Handle empty collections and missing images/location gracefully. 4. Honor supplied site.theme colors, fonts, logo, favicon, hero background, and overlay opacity where appropriate to the custom design. Support site.layoutPreset metadata (festival-poster, editorial, minimal) without requiring our CSS. site.content contains heroEyebrow, heroSubheading, scheduleSummary, primaryCtaLabel, contactCtaLabel, metaTitle, metaDescription, successTitle, successBody, successChecklist, cancelTitle, cancelBody, cancelPrimaryCtaLabel, and cancelSecondaryCtaLabel. All customization fields may be absent; provide sensible fallbacks. 5. Add SEO title/description, accessible headings, responsive images, keyboard navigation, focus management, labeled form errors, and loading/empty/error states. Sanitize rich HTML from event descriptions, FAQs, and policies before rendering; validate external link protocols. 6. Create home, contact, policy-detail, success, and cancel routes, supporting any subpath where this site is deployed. Policy details come from the loaded policies collection; there is no separate policy-fetch endpoint. Unknown policy IDs should show 404. 7. Parse date strings as instants. Use the configured event time zone consistently for grouping days and displaying date/time; do not silently group by the visitor's device zone. The API does not provide an event time-zone field, so obtain it from the operator. ## Ticket selection and checkout Build a mobile-friendly checkout dialog or page with occurrence/day selection, variant quantities, buyer details, policy review/fields, order summary, and payment redirect. - Occurrences are non-deleted and have not ended, sorted by start time; already-started occurrences may still be returned. Allow selecting a day and then a specific occurrence when multiple slots share a day. - Show active variants available to that occurrence: availabilityMode="all_occurrences", or availabilityMode="selected_occurrences" and occurrenceIds includes the chosen occurrence ID. Clear quantities that become invalid when switching occurrences. A missing stripePriceId means the variant cannot be purchased online. - Use whole-number quantities 0–30 in the UI; submit only positive selections, once per variant ID. Require at least one ticket. Respect configured capacity limits when present, including zero. These values are not live remaining inventory; do not label them "tickets left." - A selected requireOtherTicket=true variant requires at least one selected variant with requireOtherTicket=false in the same order. - Prices are integer USD cents. Calculate the ticket subtotal as sum(price * quantity), formatting as USD. When forwardFeesToCustomer is true, indicate additional processing fees; Stripe may add automatic tax. There is no public quote endpoint. Do not promise a final total or hardcode fee formulas: the backend and Stripe determine the payable total. - Collect valid email plus first and last names to match the microsite (names are optional at API level). Use the project's form library; for this monorepo, use its TanStack Form hook and Zod. Follow the contract creation and signing workflow below before submitting checkout. POST /events with JSON: { "occurrenceId": "", "email": "visitor@example.com", "firstName": "Alex", "lastName": "Rivera", "variants": [{ "variantId": "", "quantity": 2 }], "successUrl": "//success", "cancelUrl": "//cancel", "trackingSessionToken": "", "policySignatures": [{ "policyId": "", "placeholderValues": { "Customer Name": "Alex Rivera" } }] } Construct absolute return URLs from trusted frontend configuration and enforce their origin in your proxy. Do not accept arbitrary user-supplied redirect destinations. Never submit prices, Stripe price IDs, fees, or payment card details. Disable repeat submissions while checkout is pending. The response is {sessionId, sessionUrl, orderId}; navigate to non-null sessionUrl. If it is missing, show a recoverable error. There is no client idempotency key: automatically retrying a timed-out checkout can create another order/reservation. Preserve selections and let the visitor explicitly retry after an explanation. On 400 or 409, show the backend's useful validation/availability message, refresh event data when appropriate, and let the visitor choose again. Parse both {message} and {error: {name, message}} validation errors; the latter message can contain JSON-encoded Zod issues. Fall back gracefully for plain-text or malformed 5xx responses. ## Contracts: create templates and integrate signing In StubJoy, a contract, waiver, or set of terms is an event **policy**. A completed contract is an order-linked **policy signature**. The public integration uses the policies array and POST /events.policySignatures; it does not create templates or upload signed PDFs. ### 1. Organizer creates the contract in StubJoy An authorized organization administrator opens the event's **Policies** page (admin route /org//events//policies), chooses **Add Policy**, enters a non-empty **Title** and **Content**, optionally chooses a Lucide icon name, uses **Preview**, and saves. The Content field is a rich-text editor: enter formatted text and literal placeholder tokens in that editor, rather than pasting raw HTML source into its visual editing surface. Have the organizer supply the actual approved contract wording. Templates are scoped to an event, not a particular ticket variant or occurrence. There is no required/optional flag or per-variant applicability field in the public policy model. Display every returned policy for that event. Saving a policy makes it available in public event data immediately; there is no separate policy publish step. The site resolver still requires the microsite itself to be published. For an authorized internal admin integration, the existing authenticated oRPC procedure is admin.policies.create with {eventId, title, content, icon?}; content is an HTML string and icon is optional. It verifies organization ownership and returns the created policy with its generated id, version=1, isLatest=true, and parentPolicyId=null. This is an internal admin procedure name, not a public REST endpoint. Use the existing admin app/client and its authenticated organization context; never expose admin credentials or template editing in the visitor checkout, and do not invent POST /policies or POST /contracts. Template excerpt illustrating data placement (append to the organizer's actual contract text): Event: {{Event Name}} Event date: {{Event Date}} Purchaser: {{Customer Name}} Date: {{Date}} Attendees: {{Attendees List}} Emergency contact: {{Emergency Contact}} Keep each token contiguous: do not apply formatting to only part of {{Customer Name}}, put tags inside its name, or place placeholders in HTML attributes, URLs, scripts, or styles. They are text-content substitutions, not executable templates. There is no support for loops, conditionals, nested objects, optional fields, or a separate field-definition schema. ### 2. Load the current contract version Use policies from GET /events/site/resolve or GET /events/{eventId}. Each includes id, title, content, version, isLatest, and parentPolicyId. Public reads return only latest, non-deleted versions. Read-only policy pages must show the template safely; the checkout view renders the same terms with the customer's completed fields. Editing through admin.policies.update creates a NEW policy ID and increments its version, linking versions through parentPolicyId. Always send the current returned id, never the parentPolicyId, an old saved ID, title, or a locally invented identifier. Refetch event policies when opening checkout and immediately before submission. If IDs, versions, or content have changed, show the updated terms and require review/acceptance again before checkout. Refreshing reduces stale-version errors but cannot eliminate a change racing with POST /events: the current API has no policy-version precondition and does not reject a stale signature ID. ### 3. Collect and validate every placeholder Extract tokens with `/\{\{([^}]+)\}\}/g` and trim the captured name. Deduplicate by the exact trimmed name within each policy, maintaining separate values for each policy ID. Matching built-in names is case-insensitive; submitted keys must retain the original spelling/case. For example, {{ Customer Name }} uses "Customer Name", while {{customer name}} uses "customer name". If both spellings occur, supply both keys. | Placeholder | Value / input behavior | | --- | --- | | Customer Name | Full buyer name, updated when buyer details change. | | Date | Current date as YYYY-MM-DD; the hosted microsite uses new Date().toISOString().split("T")[0] (UTC). | | Event Name | Current event title. | | Event Date | Selected occurrence's formatted date, using the configured event time zone. | | Attendees List or Attendee List | One non-empty attendee name per purchased ticket, grouped by variant. | | Any other name | Required text input; e.g. Emergency Contact. No automatic special behavior is defined for names such as Signature or Guardian Name. | Autofilled values must be present too. Keep buyer/occurrence/quantity changes synchronized with the contract preview. If the cart changes, rebuild attendee fields to match the selected quantities and require acceptance of the updated completed content. For attendees, collect names per variant ID and ticket index locally. Collapse them into ONE string under the original placeholder key, in selected-variant order, such as "General admission:\nAlex Rivera, Sam Rivera; Child:\nTaylor Rivera". The \n sequences in JSON represent newlines. Do not submit arrays, objects, or internal field keys such as Attendees List_variant__0. Attendee names in policy text do not by themselves assign names to individual ticket records. Display the full terms and a completed preview, require all fields to be non-empty, and require an explicit unchecked-by-default acceptance control for each contract. Keep acceptance local: the API has no accepted boolean, typed-signature field, drawn-signature image, signature-provider integration, or cryptographic signature field. A placeholder named Signature is just another string. Do not claim stronger signature verification than the API supplies. Render template HTML through an allowlist sanitizer and render customer text as text nodes. The backend replaces placeholders directly without HTML escaping; with templates restricted to text-node placeholders, HTML-escape customer values before submission (at least &, <, >, double quote, and single quote) so the stored signedContent cannot become injected markup. Apply escaping once at the submission boundary, not repeatedly as the user types; use readable raw values in inputs. Never place sensitive policy answers in URLs, analytics, console logs, or persistent browser storage. ### 4. Submit the completed contracts with checkout POST /events includes one policySignatures entry per CURRENT policy ID, even if a template has no placeholders. Example fragment for the template above and a second static policy: { "policySignatures": [ { "policyId": "", "placeholderValues": { "Event Name": "Summer Festival", "Event Date": "September 26, 2026", "Customer Name": "Alex Rivera", "Date": "2026-09-24", "Attendees List": "General admission:\nAlex Rivera, Sam Rivera", "Emergency Contact": "Sam Rivera" } }, { "policyId": "", "placeholderValues": {} } ] } This fragment is part of the full checkout body shown earlier; it is not a separate signing request. Send strings only, one entry per policy ID, and only keys found in that policy's template. Send policyId and placeholderValues; do not send signedContent, signedAt, orderId, parentPolicyId, version, or a signature file. The server generates the order and signature records. Current backend behavior that the frontend MUST account for: - policySignatures and placeholderValues are optional in request validation. The server does not require all event policies, confirm that the user saw/accepted them, check all fields, or reject duplicate signature entries. Enforce completeness, explicit acceptance, and deduplication in the frontend; repeat validation in your own checkout proxy when available. - Only IDs matching a latest, non-deleted policy on the occurrence's event are used. Unknown, old-version, deleted, or other-event IDs are silently ignored. A 200 checkout response does not certify that all requested signatures were saved. - The server replaces each {{name}} using the exact trimmed key. Missing or empty values leave the original token in signedContent; extra keys do not fill any token. Block unresolved placeholders before submission. - Signature records are inserted with the pending order before Stripe payment, not upon payment confirmation. Each stores a generated id, orderId, policyId, the substituted signedContent snapshot, serialized placeholderValues, and server-generated signedAt. A stored signature is not proof of successful payment; it can exist on a canceled or abandoned order. - The public checkout response returns sessionId, sessionUrl, and orderId, not signature IDs or completed contract text. There is no public signature-retrieval, contract-download, or post-checkout signing endpoint. If the integration requires server-enforced contract completeness, atomic version checking, or verifiable signature evidence, flag these as backend work before launch rather than presenting frontend checks as those guarantees. ### 5. Verify the saved record with the organizer Using an operator-approved test event, create a contract with all built-in placeholders, a custom field, repeated tokens, and a second policy without placeholders. Complete the custom frontend checkout, then have an authorized administrator use **View Signed Policies** on the event's Policies page. Find the buyer/order and open **Signed Policy Details**. Internal admin procedures admin.policies.listSignedPolicies and admin.policies.getSignedPolicy power this view; they are not public visitor endpoints. Confirm the saved order reference, current policy version ID, completed signedContent, placeholderValues, and signedAt match what was displayed and submitted; use the authenticated admin client for fields not exposed in the details dialog. Confirm no {{...}} tokens remain, attendee counts match quantities, repeated tokens are filled consistently, and markup-like input displays as text. Check the order's payment status separately. Repeat with an edited contract during an open checkout, changed attendee quantities, a missing field, a stale policy ID, no policies, a static-only policy, and canceled/failed payment. The normal UI must block incomplete or unaccepted contracts and request new acceptance after relevant changes. The stale-ID test should document the current server's silent-ignore behavior rather than assume rejection. ## Payment return pages and fulfillment Use site.content's success/cancel copy and successChecklist with suitable defaults, plus links home and to contact the organizer. The cancel page should allow another purchase attempt. The backend handles Stripe webhooks, fulfillment, ticket/QR issuance where enabled, and confirmation email. The success URL is navigable by anyone and is NOT payment verification. There is no public order-status or ticket-download endpoint. Do not synthesize tickets, mark orders paid, fire a purchase-completed event, or display a verified receipt based only on URL parameters. Explain that confirmation/tickets arrive by email after processing. Returning to cancel does not itself cancel or release a reservation; backend expiry/cleanup handles abandoned checkout. ## Contact form POST /events/contact with {eventId, name, email, message}. Require a non-empty name, valid email, and a message of 1–1000 characters. Use pending, success, and error states and prevent repeat submissions. Success returns {messageId, success: true}; this confirms storage, not guaranteed email delivery. Keep entered text on errors and avoid automatic retries after an ambiguous timeout. ## Attribution and funnel analytics Analytics must be best-effort, have bounded timeouts, and never block browsing, contact, or checkout. Respect Do Not Track, Global Privacy Control, and this site's consent settings; handle unavailable storage without breaking purchases. - Create a session with POST /track/sessions, normally using eventId, channel, referrer, utmSource, utmMedium, and utmCampaign. Alternatively use orgSlug. It returns {sessionId}; sessions expire 30 minutes after creation. - Channel is one of organic, instagram, email, direct, referral, paid. Match the microsite's precedence: paid media values cpc/ppc/paid/paid_social/display/affiliate first; email/e-mail/newsletter medium next; instagram/ig source; email/newsletter source; any other nonempty source is referral. Without campaign source, classify Instagram referrers as instagram, search engines as organic, other external referrers as referral, otherwise direct. - Reuse unexpired sessions across internal navigation; start a new one on new campaign parameters or an external referrer. Honor incoming bz_s attribution links when tracking is allowed. Cache expiry locally, and recheck it during a long-lived page session. The backend validates tokens and may ignore expired/unrelated ones. - Pass sessionId as trackingSessionToken in POST /events, and as sessionId in analytics payloads. - POST /track/events with id (crypto.randomUUID), eventId, optional occurrenceId/sessionId/anonymousId, name, path, and properties. Use event_viewed once per event page load, checkout_started when opening checkout, ticket_selected after confirming ticket selection, and checkout_redirected just before navigating to Stripe. Use keepalive where supported for redirect events. - Generate an anonymous visitor ID without personal data. Use pathname without sensitive query values. Useful properties include ticket_count, variant_count, and subtotal_cents; values must be string/number/boolean/null, not nested objects. Follow documented size limits. Never include email, names, or policy answers. - Reuse an event id when retrying that same logical analytics event; duplicate IDs are deduplicated. A 202 {ok:true} is best-effort acceptance, not a durability guarantee. - POST tracking requests require an approved Origin. Handle missing_origin/invalid_body (400), origin_not_allowed (403), and unknown_org/unknown_event (404) as setup/telemetry errors, without interrupting a purchase. For a site that only links to hosted checkout, the optional GET /track.js helper can be embedded with data-org and data-api, and links marked data-stubjoy-checkout (or a data-checkout-selector override). It adds bz_s tokens to links. It does not implement full funnel analytics, custom checkout, or privacy gating itself. Use the explicit tracking endpoints for this complete custom-frontend integration. ## Delivery and acceptance checks Implement the integration, document configuration/domain setup, and report any missing operator inputs rather than inventing production values. Test against the operator's test environment: - Published-site resolution, missing/unpublished site, missing location/images, and empty content. - All home sections, customization fallbacks, contact and policy routes, accessible mobile checkout. - Multiple occurrences on one day, explicit event time zone, and changing occurrences after choosing restricted variants. - Normal ticket, add-on without base ticket, zero selection, maximum quantities, unavailable payment variant, and stale capacity/availability. - The full contract acceptance checklist above, including template creation, version changes, complete substitution, and organizer verification of saved signatures. - Successful Stripe test checkout, cancel/back navigation, null session URL, network timeout, duplicate-click prevention, and readable 400/409/non-JSON 5xx errors. - Contact success and validation/error recovery using an approved test message. - Attribution through checkout, correct funnel events, expiry/new campaigns, duplicate analytics IDs, consent denial, blocked storage, and analytics outages. - Production CORS and tracking-domain configuration verified in a browser before launch. Deliver a working frontend, a short setup README, and verification results. List any backend capability gaps explicitly; do not invent APIs for unsupported order lookup, refunds, inventory counts, or ticket downloads.