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)