Skip to main content

Building Forms

This guide covers building a form-based app — from the HTML template card to submission handling, validation, success/error states, GDPR consent, and access code gating.


Overview

A form-based app typically:

  1. Shows a trigger card or entry button on the profile.
  2. Reveals a form (inline or in a bottom sheet) when the visitor clicks the button.
  3. Collects data from the visitor and submits it via shkApi.public.entries.submit().
  4. Shows a success or error state.
  5. Gives the profile owner a way to review and export entries in an admin page.

Platform Recommendation for New Apps

For new Shuuka apps, use shkApi.public.entries.submit() as the submission path.

Do not copy older legacy patterns that call:

  • window.shuuka.submitUserInput(...)
  • sdk.submitUserInput(...)
  • raw POST requests to /app-user-inputs

Those older flows still exist in some legacy apps, but they are not the recommended platform contract for new Simple, Smart, or Native form-like apps.

If you inspect the existing Smart contact app, treat it as a UI reference, not as the preferred submission transport.


The Files You Need

FilePurpose
manifest.jsonDeclares the app, privacy, and required inputs.json schema
inputs.jsonDefines which fields are collected (used for GDPR export)
template.html or index.htmlThe form UI
settings.jsonOwner-configurable copy and options
admin/participants.htmlOwner admin page to review entries

inputs.json — Define What You Collect

Every form app must declare its collected fields. This drives the GDPR data export and validates submissions server-side.

{
"schema_version": "1.0.0",
"collection_fields": [
{ "id": "full_name", "type": "text", "label": "full_name", "collect": true },
{ "id": "email", "type": "email", "label": "email", "collect": true }
]
}

The id values must exactly match the keys you send in inputValues when calling entries.submit().

Current Platform Protections and Form Anti-Spam Baseline

Today, the platform already gives you:

  • encrypted inputValues storage on the entries endpoint
  • duplicate protection when the app uses an email-backed submission key
  • route throttling on the public entries endpoint

Recommended best practice for new apps:

  1. keep email in the first-version preset when duplicate protection matters
  2. add a hidden honeypot field that must remain empty
  3. record a started_at timestamp when the form opens and reject obviously too-fast submits
  4. disable repeated clicks while the request is in flight
  5. show a friendly retry message when the route throttle is hit

Important distinction:

  • route throttling and duplicate protection are platform-backed today
  • honeypot and minimum-submit-age checks are still app-side behavior unless the platform later centralizes them

Minimal Form — Simple App template.html

Simple app templates are HTML fragments (no <html>, <head>, or <body> tags). They use {{placeholder}} tokens for instance settings and an inline <script> block for interactivity.

<div class="form-app" data-app="{{app_id}}">

<!-- Trigger view -->
<div class="form-app__trigger" data-view="idle">
<h2 class="form-app__title">{{campaign_title}}</h2>
<p class="form-app__desc">{{campaign_description}}</p>
<button class="form-app__cta shk-host-btn" data-action="show-form">
{{button_label}}
</button>
</div>

<!-- Form view -->
<form class="form-app__form" data-view="form" style="display:none" novalidate>
<h3 class="form-app__form-title">Enter your details</h3>

<div class="form-app__field">
<label for="fa-name-{{app_id}}">Full name</label>
<input id="fa-name-{{app_id}}" name="full_name" type="text" placeholder="Jane Doe" required autocomplete="name">
</div>

<div class="form-app__field">
<label for="fa-email-{{app_id}}">Email address</label>
<input id="fa-email-{{app_id}}" name="email" type="email" placeholder="[email protected]" required autocomplete="email">
</div>

<p class="form-app__error" data-view="error" style="display:none">
Something went wrong. Please try again.
</p>
<p class="form-app__duplicate" data-view="duplicate" style="display:none">
You have already entered. Thank you!
</p>

<label class="form-app__consent">
<input type="checkbox" name="consent" required>
I agree to share my data with the profile owner.
</label>

<button type="submit" class="form-app__submit shk-host-btn">Submit</button>
<button type="button" class="form-app__back" data-action="show-idle">Back</button>
</form>

<!-- Success view -->
<div class="form-app__success" data-view="success" style="display:none">
<p class="form-app__success-icon"></p>
<h3>You're in!</h3>
<p>Thanks for entering, <strong class="form-app__winner-name"></strong>.</p>
</div>

<script>
(function () {
'use strict';

var ctx = window.__shuukaCtx || {};
var shkApi = ctx.shkApi || window.ShuukaApi;

// Scope all queries to this app card instance
var root = document.currentScript.closest('.form-app');
if (!root) return;

// ----------------------------
// View state machine
// ----------------------------
var views = root.querySelectorAll('[data-view]');

function setState(name) {
views.forEach(function (el) {
el.style.display = el.dataset.view === name ? '' : 'none';
});
}

// ----------------------------
// Action delegation
// ----------------------------
root.addEventListener('click', function (e) {
var target = e.target.closest('[data-action]');
if (!target) return;

switch (target.dataset.action) {
case 'show-form': setState('form'); break;
case 'show-idle': setState('idle'); break;
}
});

// ----------------------------
// Form submission
// ----------------------------
var form = root.querySelector('.form-app__form');
var submitBtn = root.querySelector('.form-app__submit');
var errorEl = root.querySelector('.form-app__error');
var dupEl = root.querySelector('.form-app__duplicate');

form.addEventListener('submit', function (e) {
e.preventDefault();

// Client-side validation
if (!form.checkValidity()) {
form.reportValidity();
return;
}

var fd = new FormData(form);
var fullName = (fd.get('full_name') || '').trim();
var email = (fd.get('email') || '').trim();
var consent = fd.get('consent') === 'on';

if (!fullName || !email || !consent) return;

// Disable UI during submission
submitBtn.disabled = true;
submitBtn.textContent = 'Submitting…';
errorEl.style.display = 'none';
dupEl.style.display = 'none';

shkApi.public.entries.submit({
formId: 'giveaway',
inputValues: { full_name: fullName, email: email },
displayValue: fullName, // public-safe nickname shown in winner draws
metadata: { consent: consent }
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.success) {
root.querySelector('.form-app__winner-name').textContent = fullName;
setState('success');
} else if (d.message === 'already_entered') {
dupEl.style.display = 'block';
submitBtn.disabled = false;
submitBtn.textContent = 'Submit';
} else {
throw new Error(d.message || 'Unknown error');
}
})
.catch(function (err) {
console.error('Submission error:', err);
errorEl.style.display = 'block';
submitBtn.disabled = false;
submitBtn.textContent = 'Submit';
});
});

// ----------------------------
// Initial state
// ----------------------------
setState('idle');

})();
</script>
</div>

Form Styles

Minimal styles to get a clean, functional form. Add to your app's CSS or inline in the template:

.form-app {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
padding: 20px;
max-width: 420px;
margin: 0 auto;
color: inherit;
}

.form-app__title {
font-size: 1.2rem;
font-weight: 700;
margin: 0 0 8px;
}

.form-app__desc {
font-size: 0.875rem;
opacity: 0.7;
margin: 0 0 20px;
line-height: 1.5;
}

.form-app__cta,
.form-app__submit {
width: 100%;
padding: 12px;
border-radius: 10px;
font-size: 0.9rem;
font-weight: 700;
font-family: inherit;
cursor: pointer;
}

.form-app__form-title {
font-size: 1rem;
font-weight: 700;
margin: 0 0 16px;
}

.form-app__field {
margin-bottom: 12px;
}

.form-app__field label {
display: block;
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 4px;
opacity: 0.8;
}

.form-app__field input {
width: 100%;
padding: 10px 12px;
border: 1px solid rgba(0,0,0,0.15);
border-radius: 8px;
font-size: 0.9rem;
font-family: inherit;
background: #fff;
color: #111;
box-sizing: border-box;
}

.dark .form-app__field input {
background: rgba(255,255,255,0.06);
border-color: rgba(255,255,255,0.15);
color: #fff;
}

.form-app__consent {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 0.8rem;
opacity: 0.7;
margin: 12px 0;
cursor: pointer;
}

.form-app__back {
width: 100%;
margin-top: 8px;
padding: 8px;
background: transparent;
border: 1px solid rgba(0,0,0,0.12);
border-radius: 8px;
font-size: 0.875rem;
cursor: pointer;
}

.form-app__error,
.form-app__duplicate {
font-size: 0.85rem;
color: #dc2626;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 8px 12px;
margin-bottom: 8px;
}

.form-app__success {
text-align: center;
padding: 24px 16px;
}

.form-app__success-icon {
font-size: 2.5rem;
margin: 0 0 8px;
}

Access Code Gating

If the campaign is invite-only, verify a code before revealing the form.

// Add an access code step to the state machine:
// idle → access-code → form → success

var accessToken = null; // stored in memory only, never localStorage

function verifyCode(code) {
return shkApi.public.access.verify(code)
.then(function (r) {
if (!r.ok) return r.json().then(function (d) { throw new Error(d.message); });
return r.json();
})
.then(function (d) {
accessToken = d.token; // keep token in memory
});
}

// When submitting with an access token:
shkApi.public.entries.submit({
formId: 'giveaway',
inputValues: { email: email },
displayValue: nickname,
accessToken: accessToken // ← include the verified token
});

Access code token rules:

  • Store the token in memory only.
  • Tokens expire after 15 minutes.
  • Never store in localStorage or sessionStorage.
  • Rate limit: 5 wrong attempts per IP per hour returns 429.

Bottom Sheet Form Pattern

For a mobile-style sheet experience:

// In template.html — open the sheet when the CTA is clicked:
window.parent.postMessage({
type: 'SHUUKA_APP_BOTTOM_SHEET',
appId: String(window.__shuukaCtx && window.__shuukaCtx.billboardAppId || ''),
action: 'open',
state: 'form',
height: 520
}, '*');

// When the form submission succeeds, close the sheet:
window.parent.postMessage({
type: 'SHUUKA_APP_BOTTOM_SHEET',
appId: String(window.__shuukaCtx && window.__shuukaCtx.billboardAppId || ''),
action: 'done'
}, '*');

Declare bottom_sheet_contract in manifest.json:

{
"bottom_sheet_contract": "The card CTA sends SHUUKA_APP_BOTTOM_SHEET {action:'open'} to the parent. The sheet reloads the app in bottom-sheet mode (query param present). The form renders directly. On success, sends {action:'done'} to close the sheet."
}

Read App SDK UI Helpers for the full bottom sheet reference.


React Form Pattern

For React apps, the same flow works through the SDK hooks:

import { useState } from 'react';
import { useShuuka } from '@shuuka/sdk/index.sdk.js';

function GiveawayForm() {
const { sdk } = useShuuka();
const [state, setState] = useState('idle'); // idle | form | submitting | success | error | duplicate
const [name, setName] = useState('');
const [email, setEmail] = useState('');

async function handleSubmit(e) {
e.preventDefault();
setState('submitting');

try {
const r = await sdk.api.public.entries.submit({
formId: 'giveaway',
inputValues: { full_name: name, email },
displayValue: name,
metadata: { consent: true }
});
const d = await r.json();

if (d.success) setState('success');
else if (d.message === 'already_entered') setState('duplicate');
else setState('error');
} catch {
setState('error');
}
}

if (state === 'idle') return (
<div>
<button onClick={() => setState('form')}>Enter the giveaway</button>
</div>
);

if (state === 'success') return <p>You're in, {name}!</p>;
if (state === 'duplicate') return <p>You've already entered.</p>;
if (state === 'error') return <p>Something went wrong.</p>;

return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={e => setName(e.target.value)} placeholder="Your name" required />
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="Your email" type="email" required />
<button type="submit" disabled={state === 'submitting'}>
{state === 'submitting' ? 'Submitting…' : 'Submit'}
</button>
</form>
);
}

Submission Response Codes

StatusBodyMeaning
200{ success: true, entry_id: N }Entry recorded
409{ message: "already_entered" }Duplicate (same email, same deduplication mode)
403{ message: "Access code required." }Access code protected — missing or invalid token
422Validation errorsInput validation failed
429{ message: "too_many_attempts" }Rate limit hit (access code endpoint)

Always handle already_entered gracefully — it is not an error. Show a friendly "You're already in!" message.


Checklist Before Shipping a Form App

  • inputs.json declares every field you collect
  • privacy.collects_pii: true is set in manifest.json
  • Consent checkbox is present and required
  • display_value never contains real name or email (use nickname or first name only)
  • Error, duplicate, and success states are all implemented
  • Server-side deduplication mode is correct (single_entry vs daily_entries)
  • All third-party form services are declared in privacy.third_party_transfers
  • Access token is stored in memory only (never localStorage)