Custom Form Integration
Last updated: April 16, 2026
The app ships a B2B Registration Form block you add via the theme editor. If you need full control over markup and UX, you can build your own form instead and POST to the same endpoint.
Submission endpoint
POST /apps/b2b/register
Accepts application/x-www-form-urlencoded or multipart/form-data. Returns JSON.
Reading the form config
The app publishes a PUBLIC_READ shop metafield (b2b_app.form_config) with the active field schema. Read it to know which fields are enabled and their configuration.
In Liquid:
{% assign cfg = shop.metafields.b2b_app.form_config.value %}
Via Storefront API:
const { data } = await fetch('/api/2024-01/graphql.json', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': window.Shopify.storefrontAccessToken,
},
body: JSON.stringify({ query: `{
shop {
metafield(namespace: "b2b_app", key: "form_config") { value }
}
}` }),
}).then(r => r.json());
const cfg = JSON.parse(data.shop.metafield.value);
Field schema
Each object in cfg.fields:
{
key: string; // POST field name
type: string; // see field types below
label: string;
placeholder: string;
required: boolean;
options?: { value: string; label: string }[]; // select fields
countries?: { code: string; name: string }[]; // country fields
}
Field types
| Type | Input | Notes |
|---|---|---|
text | <input type="text"> | |
email | <input type="email"> | |
tel | <input type="tel"> | |
number | <input type="number"> | |
url | <input type="url"> | |
textarea | <textarea> | |
select | <select> | Options in field.options |
checkbox | <input type="checkbox"> | Submitted as "on" when checked |
country | <select> | Options in field.countries; submit the country code |
vat | <input type="text"> | Server validates format and optionally checks VIES |
name | Two inputs | Submit as first_name + last_name |
address | Three inputs | Submit as address + city + postal_code |
Field names
| Field | name attribute |
|---|---|
email | |
| Company name | company_name |
| First name | first_name |
| Last name | last_name |
| Country | country_code (ISO 3166-1 alpha-2, e.g. DE) |
| VAT / Tax ID | vat_id |
| Phone | phone |
| Street address | address |
| City | city |
| Postal code | postal_code |
| Custom fields | Use field.key from the metafield config |
email and company_name are always required regardless of field config.
Handling the response
| Status | Body | Meaning |
|---|---|---|
| 200 | { ok: true } | Submitted. Also returned for duplicate submissions (idempotent). |
| 400 | { error: "..." } | Missing or invalid field |
| 403 | { error: "Registration form is not enabled" } | Form disabled in settings |
| 422 | { error: "VAT / Tax ID format is not valid for this country." } | VAT format check failed |
| 429 | { error: "Your store has reached its application limit. Please upgrade your plan to continue accepting applications." } | Plan application limit reached |
Display result.error directly - the messages are user-readable.
Complete Liquid example
{% assign cfg = shop.metafields.b2b_app.form_config.value %}
{% if cfg == blank %}
<p>Form not configured yet.</p>
{% elsif customer and customer.tags contains 'b2b-accepted' %}
<p>You already have an active B2B account.</p>
{% elsif customer and customer.tags contains 'b2b-pending' %}
<p>Your application is under review.</p>
{% else %}
<div id="b2b-wrap">
<div id="b2b-success" style="display:none">
<p>Application received. We'll be in touch.</p>
</div>
<form id="b2b-form" novalidate>
{% for field in cfg.fields %}
{% if field.type == 'name' %}
<label>First name
<input type="text" name="first_name" {% if field.required %}required{% endif %}>
</label>
<label>Last name
<input type="text" name="last_name" {% if field.required %}required{% endif %}>
</label>
{% elsif field.type == 'address' %}
<label>{{ field.label }}
<input type="text" name="address" {% if field.required %}required{% endif %}>
</label>
<label>City
<input type="text" name="city" {% if field.required %}required{% endif %}>
</label>
<label>Postal code
<input type="text" name="postal_code" {% if field.required %}required{% endif %}>
</label>
{% elsif field.type == 'country' %}
<label>{{ field.label }}
<select name="country_code" {% if field.required %}required{% endif %}>
<option value=""></option>
{% for c in field.countries %}
<option value="{{ c.code }}">{{ c.name }}</option>
{% endfor %}
</select>
</label>
{% elsif field.type == 'select' %}
<label>{{ field.label }}
<select name="{{ field.key }}" {% if field.required %}required{% endif %}>
<option value=""></option>
{% for opt in field.options %}
<option value="{{ opt.value }}">{{ opt.label }}</option>
{% endfor %}
</select>
</label>
{% elsif field.type == 'textarea' %}
<label>{{ field.label }}
<textarea name="{{ field.key }}" placeholder="{{ field.placeholder }}" {% if field.required %}required{% endif %}></textarea>
</label>
{% elsif field.type == 'checkbox' %}
<label>
<input type="checkbox" name="{{ field.key }}" {% if field.required %}required{% endif %}>
{{ field.label }}
</label>
{% else %}
<label>{{ field.label }}
<input type="{{ field.type | replace: 'vat', 'text' }}" name="{{ field.key }}"
placeholder="{{ field.placeholder }}" {% if field.required %}required{% endif %}>
</label>
{% endif %}
{% endfor %}
<p id="b2b-error" style="display:none; color:red;"></p>
<button type="submit">Apply for wholesale access</button>
</form>
</div>
<script>
(function () {
var wrap = document.getElementById('b2b-wrap');
var form = document.getElementById('b2b-form');
var errorEl = document.getElementById('b2b-error');
var successEl = document.getElementById('b2b-success');
form.addEventListener('submit', async function (e) {
e.preventDefault();
errorEl.style.display = 'none';
try {
var res = await fetch('/apps/b2b/register', { method: 'POST', body: new FormData(form) });
var data = await res.json();
if (data.ok) {
form.style.display = 'none';
successEl.style.display = '';
} else {
errorEl.textContent = data.error || 'Something went wrong.';
errorEl.style.display = '';
}
} catch (_) {
errorEl.textContent = 'Network error. Please try again.';
errorEl.style.display = '';
}
});
})();
</script>
{% endif %}