Web Forms
Build public web forms with server-side context loading, client-side validation, kiosk-style auto-submit, and payments.
On this page
Public-facing data entry forms with custom server-side context loading, client-side validation, field binding, and payment integration.
File Structure
mymodule/web_form/my_form/
├── my_form.json # Web form definition (fields, payment config, routes)
├── my_form.py # Server-side get_context() hook
├── my_form.js # Client-side logic
└── block_form.json # Custom Builder layout (optional)Web Form JSON Configuration
Key configuration fields in the JSON definition:
{
"doctype": "Web Form",
"name": "my-form",
"doc_type": "Player Application",
"route": "my-form",
"module": "My Module",
"is_standard": 1,
"published": 1,
"login_required": 0,
"allow_edit": 0,
"allow_delete": 0,
"allow_multiple": 0,
"button_label": "Submit & Pay",
"success_message": "Thank you for your submission!",
"breadcrumbs": "[{\"label\": \"Home\", \"route\":\"/\"},{\"label\": \"Apply\", \"route\":\"apply\"}]",
"accept_payment": 1,
"payment_gateway": "Stripe-Credit Card",
"currency": "USD",
"amount_based_on_field": 1,
"amount_field": "fee",
"payer_email_based_on_field": 1,
"payer_email_field": "email",
"payer_name_based_on_field": 1,
"payer_name_field": "name_full"
}| Property | Purpose |
|---|---|
login_required | 0 for public/guest access, 1 for authenticated only |
accept_payment | Enable payment gateway integration |
amount_based_on_field | Payment amount comes from a form field value |
amount_field | Which field contains the payment amount |
payer_email_field / payer_name_field | Fields for payment gateway payer info |
allow_edit / allow_delete | Post-submission capabilities |
is_standard | Part of the app (version controlled) |
show_list | Show list view of submitted records |
apply_document_permissions | Respect doctype-level permissions |
Server-Side Context Loading
The get_context(context) function runs before rendering and receives a mutable context dict:
import frappe
from frappe.query_builder import DocType
from frappe.utils import format_date
def get_context(context):
# 1. Custom template (optional)
if not frappe.form_dict.is_list:
context.template = "templates/web_form/my_form.html"
# 2. Business logic gating
current_season = get_current_season()
force = frappe.local.request.args.get("force")
if not current_season.is_open() and not force:
context.enrollment_closed = True
context.message = "<strong>Enrollment is closed.</strong>"
return
# 3. Dynamic introduction text
close_date = format_date(current_season.enrollment_close_date)
fee = frappe.format_value(current_season.fee, {"fieldtype": "Currency"})
context.introduction_text = f"Entries due by {close_date}. Fee: {fee}."
# 4. Pre-fill form fields
context.reference_doc["fee"] = current_season.get_fee()
context.reference_doc["season"] = current_season.name
# 5. Sidebar for logged-in users
if frappe.session.user != "Guest":
context.show_sidebar = True
context.sidebar_items = frappe.get_all(
"Website Sidebar Item",
filters={"parent": "My Portal"},
fields=["title", "route", "group"],
order_by="idx",
)
# 6. Pre-fill from logged-in user's contact
if frappe.session.user == "Guest":
return
contact_name = frappe.db.exists("Contact", {"user": frappe.session.user})
if not contact_name:
return
contact = frappe.get_doc("Contact", contact_name)
context.reference_doc.update({
"name_first": contact.first_name,
"name_last": contact.last_name,
"email": contact.email_id,
"phone": contact.mobile_no,
})
# 7. URL-parameter-driven pre-fill
player_name = frappe.local.request.args.get("player")
if not player_name:
return
# Verify authorization via QueryBuilder join
Contact = DocType("Contact")
Guardian = DocType("Family Guardian")
Player = DocType("Player")
is_authorized = (
frappe.qb.from_(Contact)
.join(Guardian).on(Guardian.guardian == Contact.name)
.join(Player).on(Player.family == Guardian.parent)
.select(Player.name)
.where(Contact.email_id == contact.email_id)
.where(Player.name == player_name)
.run()
)
if is_authorized:
player = frappe.get_doc("Player", player_name)
context.reference_doc.update({
"player_first": player.first_name,
"player_last": player.last_name,
})Key Context Properties
| Property | Purpose |
|---|---|
context.reference_doc | Dict of initial form field values |
context.introduction_text | HTML shown above the form |
context.template | Override the Jinja template |
context.show_sidebar | Show/hide sidebar |
context.sidebar_items | Sidebar navigation items |
context.enrollment_closed | Custom flag for template conditionals |
Client-Side Logic
Field Change Handlers
frappe.ready(function () {
// Auto-compute combined field from parts
frappe.web_form.on("first_name", (_field, value) => {
let first = value.trim();
let last = frappe.web_form.get_value("last_name");
if (first && last) {
frappe.web_form.set_value("full_name", `${first} ${last}`);
}
});
// Trim whitespace
frappe.web_form.on("first_name", (field, value) => {
let trimmed = value.trim();
if (value !== trimmed) {
frappe.web_form.set_value("first_name", trimmed);
}
});
});Immediate Validation Feedback
frappe.web_form.on("birthdate", (field, value) => {
let age = frappe.datetime.get_diff(
frappe.datetime.get_today(), value
) / 365;
if (age > 15) {
frappe.msgprint(__("Sorry, players must be under 16."));
}
if (age < 6) {
frappe.msgprint(__("Sorry, players must be over 5."));
}
});Submit Validation
frappe.web_form.events.on("after_load", () => {
frappe.web_form.validate = () => {
let values = frappe.web_form.get_values();
if (!values.liability_release) {
frappe.msgprint("Please accept the liability release.");
return false;
}
if (values.birthdate) {
let age = frappe.datetime.get_diff(
frappe.datetime.get_today(), values.birthdate
) / 365;
if (age > 15 || age < 6) return false;
}
return true;
};
});Field Property Manipulation
frappe.ready(function () {
// Hide a field
frappe.web_form.set_df_property("computed_field", "hidden", 1);
// Custom page title
const title = document.querySelector(".web-form-title h1");
if (title) title.textContent = "Custom Form Title";
});Web Form API Reference
| Method | Purpose |
|---|---|
frappe.web_form.on(fieldname, callback) | Field change handler |
frappe.web_form.get_value(fieldname) | Get field value |
frappe.web_form.set_value(fieldname, value) | Set field value |
frappe.web_form.get_values() | Get all field values |
frappe.web_form.set_df_property(fieldname, prop, value) | Set field property |
frappe.web_form.validate | Custom validation function (return true/false) |
frappe.web_form.events.on("after_load", fn) | Hook into form lifecycle |
Kiosk Forms with Auto-Submit
A web form can act as a single-purpose kiosk, such as a shared time clock where staff scan a badge and the form records an Employee Checkin with no further clicks. The pattern combines four client-side pieces: focus the first field on load, react to that field's change, save programmatically, and send the browser straight back to a fresh form.
Lifecycle Hooks
Besides frappe.web_form.on() and validate, the web form object calls these optional functions if you assign them:
| Hook | When it runs |
|---|---|
frappe.web_form.after_load | After the form is rendered and the client script has run |
frappe.web_form.validate | At the start of save(); return false to block the save |
frappe.web_form.after_save | After the server accepts the document |
Each also fires as an event, so frappe.web_form.events.on("after_save", fn) works too.
Example: Time Clock Form
This script assumes a web form on Employee Checkin with the fields employee, log_type (Select: IN / OUT), time, and optional manual_override, manual_override_date, and manual_override_time fields for supervisors.
frappe.web_form.after_load = () => {
// Put the cursor in the badge field so a scanner can type straight into it
$("input[data-fieldname='employee']").trigger("focus");
render_log_type_buttons();
};
frappe.web_form.on("employee", (field, value) => {
if (!value) return;
// Optional: show the employee's last punch from a whitelisted method in your app
frappe
.xcall("<APP_NAME>.api.get_last_checkin", { employee: value })
.then((last) => last && frappe.show_alert(__("Last punch: {0}", [last])));
if (frappe.web_form.get_value("manual_override")) {
frappe.web_form.set_value(
"time",
`${frappe.web_form.get_value("manual_override_date")} ${frappe.web_form.get_value("manual_override_time")}`
);
} else {
frappe.web_form.set_value("time", frappe.datetime.now_datetime());
}
frappe.web_form.save();
});
frappe.web_form.after_save = () => {
// Skip the success page and go straight back to a blank form
window.location.href = "/<WEB_FORM_ROUTE>/new";
};
// Replace the log type dropdown with large toggle buttons
function render_log_type_buttons() {
const $select = $("select[data-fieldname='log_type']");
const $group = $('<div class="log-type-buttons"></div>').insertAfter($select);
$select.hide();
$select.find("option").each(function (i) {
const value = $(this).val();
if (!value) return;
const id = `log-type-${value}`;
$("<input type='radio' name='log_type_choice' class='d-none'>")
.attr({ id, value })
.prop("checked", i === 0)
.on("change", () => frappe.web_form.set_value("log_type", value))
.appendTo($group);
$("<label class='btn btn-light btn-lg mr-4'>")
.attr("for", id)
.text(__("Clock {0}", [this.textContent.toLowerCase()]))
.appendTo($group);
});
}Notes:
Read values with
get_value()one field at a time.get_values()validates mandatory fields and shows a "Missing Values Required" message if any are empty, which is noisy on a kiosk.Set the Select through
frappe.web_form.set_value()rather than jQuery's.val(), so the form's model and dependent fields stay in sync.The employee selects the log type before scanning, because the scan itself triggers the save.
Web forms in current Frappe open a blank form at
/<route>/new. Older notes that redirect to?new=1predate this.The Success URL setting on the Web Form also redirects after saving, but only after a five-second countdown. Use
after_savewhen the redirect has to be immediate.
Warning
A kiosk form that creates Employee Checkins for any employee ID will accept anyone who can reach the URL. Keep login_required on and sign the kiosk in as a dedicated low-privilege user, or restrict the route at the proxy to the kiosk's network.
Hidden Fields for Server-Side Use
Fields can be hidden from the user but populated by get_context():
{
"fieldname": "fee",
"fieldtype": "Currency",
"hidden": 1,
"default": "150"
}The server sets the actual value dynamically:
context.reference_doc["fee"] = current_season.get_fee()Payment Flow
When accept_payment: 1, form submission redirects to the payment gateway:
User fills form and clicks "Submit & Pay"
Frappe creates the document (e.g., Player Application)
Frappe generates a payment URL using the configured gateway
User is redirected to complete payment
On success, the payment status is updated on the document
The payment amount, payer email, and payer name are extracted from form fields configured in the JSON definition.
Sources
This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.