salesys-webbformular
Build and customize SaleSys web forms ("webbformulär" / public checkout forms) that create offers (avtal) on submit, optionally auto-signed or BankID-signed. Covers the web-form model and web-forms-v1 endpoints (incl. headless /submit), embedding with web_form1.js + createWebForm, web form presets (formulärmallar, settingsHtml + window.form.settings), the window.form.* scripting API in custom sections (products, fields, state, hooks, submit hook, navigation, templates, parts), handlebars helpers, [[text]] editable texts, <transition>, submit-in-place, and running BankID signing + status polling from your own UI via skaffa.salesys.se. Use whenever asked to build/edit/embed/debug a "webbformulär", "web form", "checkout", "formulär på hemsidan", or custom code inside a SaleSys form.
curl -fsSL https://skills.salesys.se/install.sh | sh -s salesys-webbformularInstalls into ~/.claude/skills/salesys-webbformular. Add --project to install into the current repo's .claude/skills.
- SKILL.md17.8 KB
- reference/transition.md2.4 KB
Web forms — public checkout forms
A web form ("webbformulär") is a public, embeddable multi-step checkout that creates an offer on submit — optionally auto-signed (one-click) or signed with BankID. Forms render in an iframe on https://skaffa.salesys.se; the API lives under https://app.salesys.se/api/offers/ (same backend — skaffa's public endpoints need no auth).
Flow: form submit → template resolved from selected product → createOffer with autoFill {addInsertsFromFields, setRecipientFromFields} → optional auto-sign → SMS/email distribution → redirect to returnUrl or offer link.
Web form model
{
"id": "...", "name": "...", // name ≤100
"steps": [ { "id": "...", "sections": [ /* one kind-property per section: */
{ "productList": { "type": "simple|advanced", "productCategoryIds?": [...], "productPrefixes?": [...],
"price?": {"text","productFieldId"}, "selectText?≤50": "...", "features?": [...] } },
{ "fieldList": { "fields": [ { "fieldId", "text?", "required?", "helpText?", "autoComplete?",
"validation?": {"date": {"afterNow?"|"beforeNow?": {"days"}}},
"defaultValueText?", "defaultValueDateDaysFromNow?",
"grouped?", "listType?": "radio|dropdown" } ],
"toggleText?", "togglePredefinedValueIds?", "excludeFieldIds?", "includeFields?" } },
{ "text": { "content": "markdown", "type": "header|regular", "style?": {...} } },
{ "check": { "id", "text", "required?" } },
{ "getData": { "url", "method", "inputs": [...],
"then": [ {"path": "$.json.path", "setFieldId?"|"setVariable?"|"setProductVariables?"} ],
"submitText?", "required?", "autoNavigate?" } }, // calls an external URL
{ "custom": { "content": "<handlebars HTML>", "variables?": {...} } },
{ "optionList": { "items": [ {"value","text","isDefault?"} ] } },
{ "textList": {...} }, { "objectList": {"orderFieldGroupId"} }, { "anchor": "top|bottom" }
] } ],
"templateIds": ["..."], // offer templates the form may use
"returnUrl?": "https://site/thanks?offer=[[link-id]]", // [[länk-id]]/[[link-id]]/[[link id]] → offer publicId
"isSmsEnabled?": true, "smsMessageTemplateId?": "...",
"isEmailEnabled?": true, "emailMessageTemplateId?": "...", "testEmails?": [...],
"automaticSign?": { "isEnabled": true, "isPreviewEnabled?": true }, // one-click sign on submit
"isPresignEnabled?": false, // BankID identify BEFORE showing the document
"bankIdSignArgsUrl?": "https://...", // external URL POSTed with the offer for extra BankID sign args
"theme?": { /* UITheme: colorBackground1, colorText1, colorPrimary, fonts, ... */ },
"allowUserId?": false, // required true for window.form.setUser / submit userId
"allowUserTeamIds?": [...], "accessTeamIds?": [...],
"country?": "se", "signaturePlace?": "...", "localization?": {"submit","previewHeader"},
"projectId?": "...", "issuer?": {...}, "scriptId?": "...",
"parentId?": "...", // form variant hierarchy
"isTemporary?": false, "isOfferView?": false,
"createUnsignedDocumentOn?": "...", "developerMessage?": "..."
}
Template resolution on submit (per selected product, from the form's templateIds): template productIds contains the product > productPrefixes prefix-match on product name > case-insensitive name match > first template whose productCategoryIds match. ⚠️ A template passed explicitly (templateId / setTemplate) must be among the form's templateIds.
Endpoints
All relative to /api/offers/. Admin endpoints use the normal SaleSys bearer token; "public" = no auth.
| Method & path | Notes |
|---|---|
GET web-forms-v1 |
List forms. |
GET web-forms-v1/:formId |
Get one (public — used for rendering). |
GET web-forms-v1/:formId/resources |
Public: resolved products/fields/templates needed to render the form. |
POST web-forms-v1 |
Create (admin). |
PUT web-forms-v1/:formId |
Update (partial at root; nested objects sent complete). |
DELETE web-forms-v1/:formId |
Delete. |
PUT web-forms-v1/ordering |
Reorder. |
POST web-forms-v1/:formId/submit |
Public — submit, creates the offer (below). |
GET web-form-updates-v1/:formId |
Audit history. |
Submit (this is what the iframe calls — you can call it headlessly):
POST /api/offers/web-forms-v1/<formId>/submit
{
"productId": "..." // or "productIds": [...] or "products": [{productId, quantity, ...}]
"fields": [ { "fieldId": "...", "value": "..." } ], // required, non-empty
"projectId?": "...", "country?": "se",
"signNow?": true, "signMethod?": "bankId",
"isTest?": true, "allowCreateDuplicate?": false,
"userId?": "...", // only if form allowUserId / allowUserTeamIds permits
"templateId?": "...", // must be in form.templateIds
"parts?": [...], "pendingOrders?": [...], "additionalPages?": [...],
"smsMessageTemplateId?": "...", "emailMessageTemplateId?": "...", "createdFrom?": "..."
}
→ { "url": "https://avtal.salesys.se/<publicId>", "offerId": "...", "linkId": "<publicId>", "isNew": true }
Duplicate detection: an existing matching offer is returned with isNew: false unless allowCreateDuplicate: true. Boolean field values are localized ("true"/"false" → "ja"/"nej"). If automaticSign.isEnabled, the offer is one-click signed and the service waits up to ~8 s for the signed status-link URL before responding.
Embedding
Put this on the customer's website (container must exist). createWebForm returns a handle with setProduct(id) / setProductId(id), setFields([{fieldId, value}]) and unload():
<script>
const s = document.createElement('script');
s.src = "https://salesys.se/scripts/web_form1.js";
document.body.appendChild(s);
s.addEventListener('load', () => {
const container = document.querySelector('#my-form');
container.innerHTML = "";
window.myForm = createWebForm(container, '<form id>', { width: '100%', lang: 'sv', test: true });
window.myForm.setProduct('<productId>');
window.myForm.setFields([{ fieldId: '<fieldId>', value: 'Karlskrona' }]);
});
</script>
All options:
Load https://salesys.se/scripts/web_form1.js, then:
createWebForm(containerElement, '<form id>', {
width: 720,
lang: 'sv' | 'no' | 'us',
theme: { colorBackground1, colorText1, colorPrimary /* , ... */ }, // overrides form theme
loadBackground: '#fff',
test: true, // creates a TEST offer — use during development
navigateNewPage: false, // redirect top window vs stay in iframe
onSubmit: (result) => {},
attributes: {}, // extra iframe attributes
query: {}, // extra query params to the iframe URL
onMessage: (msg) => {}, // iframe postMessages; preventDefault-able
autoScrollOffset: 0 // scroll to form minus N px (your sticky header height)
});
window.form API (inside custom section scripts)
Custom sections (custom.content) run inside the form iframe and can script it via window.form:
| Area | Methods |
|---|---|
| Products | setProduct(product) / setProductId(id), setProducts([id, ...]) (multiple products), setProductField(fieldId, value, indexOrProductId?) (default = first product), setProductFields(arrayOrFn, indexOrProductId?) ([] clears), setProductQuantity(nOrFn, productId?), setProductPrice(nOrFn, indexOrProductId?) — setProduct keeps existing product fields |
| Fields | setField(fieldId, value), setFields([{fieldId, value}, ...]) (old fields kept; overwrite with "" to clear); read current input: window.form.input.fields[fieldId], input.productId, input.parts |
| Seller | setUser(userId) — ⚠️ requires the form's allowUserId: true (PUT /api/offers/web-forms-v1/<id>) |
| Template | setTemplate(templateId | null) — must be among the form's templateIds |
| Pages | setAdditionalPages([...]), addAdditionalPage(...), removeAdditionalPage(...) |
| Navigation | next(), previous(), hideNavigateNext(reason?) / showNavigateNext(reason?) — reasons stack: every hide-reason must be shown again |
| Hooks | addHook(name, fn) — fires on state changes. ⚠️ Compare old vs new value before calling a setter inside a hook, or you create an infinite loop. setSubmitHook(async fn) — runs before submit; return {preventDefault: true} to skip the built-in submit entirely (own submit logic, e.g. open BankID in a new tab). |
| Parts | setPart(name, {fields, name}) — multi-part offers |
| Layout | setContainerStyle({ padding: '24px 48px' }) — ⚠️ only in kompletteringsformulär (supplementary forms the customer fills in on avtal.salesys.se) |
| State | setState({...}) or setState(prev => ({...})) (NOT for fields/products — use the setters); read back via window.form.custom (also custom.* in handlebars) |
| Submit | window.form.internal.submit({redirect: false}) → Promise of {offerId, url} or null — submit-in-place without redirect (pair with hideNavigateNext()). Properties: internal.offerId, internal.offerUrl, internal.isPendingRedirect, internal.submitResult, internal.isSubmitting. |
Handlebars in custom.content
Own state is custom.*, form input is input.* (input.fields.<fieldId>, input.products, ...).
{{#if custom.isOn}}On{{else}}Off{{/if}}
{{#ifEq input.fields.<fieldId> "Johan"}}Jag känner igen dig.{{/ifEq}}
{{#either custom.a custom.b}}...{{/either}} {{#ifLt custom.age custom.minAge}}...{{/ifLt}}
{{#ifNot custom.approved}}...{{/ifNot}} {{#ifIncludes custom.allowed custom.age}}...{{/ifIncludes}}
Hej {{input.fields.<fieldId>}} - nästa år är du {{math input.fields.<ageFieldId> "+" 1}}
Du börjar {{formatDate custom.startDate "DD/MM/YYYY"}}
{{!-- loop selected products, read a product field --}}
{{#each custom.products}}
{{#with (lookup ../input.products id)}}Nummer att portera: {{fields.<fieldId>}}{{/with}}
{{/each}}
lookup obj prop = obj[prop]; ../ steps out of the each context. Animated swaps: <transition lock="{{custom.list.length}}"> (see reference/transition.md).
Helpers: if/else, each, with, lookup (e.g. {{#with (lookup ../input.products id)}}), ifEq, either, ifLt, ifGt, ifNot, ifIncludes, math ({{math @index "+" 1}}), formatDate ({{formatDate date "YYYY-MM-DD"}}).
Editable texts: [[text 'identifier']] placeholders are replaced (with texts editable in the form settings UI) before the HTML/handlebars is evaluated — works inside <script> strings too, so use it for every user-facing string (translations, validation messages) so non-devs can edit them.
Status polling + BankID signing from your own page
After a headless /submit (or submit-in-place) you have an offerId — drive signing yourself with the public skaffa.salesys.se endpoints.
GET https://skaffa.salesys.se/api/offers/offer-information-v1/status?offerIds=<id> // poll status
POST https://skaffa.salesys.se/api/offers/bankid-v2/sign/<id> {identityNumber?, redirectUrl?}
→ {sessionId, autoStartToken, qrStartToken, signLink, cancelToken} // valid ~1 min
GET https://skaffa.salesys.se/api/offers/bankid-v2/qr/<id>?startToken=<qrStartToken> // refresh every 1 s
GET https://skaffa.salesys.se/api/offers/offer-information-v1/bankIdSession?offerIds=<id> // poll session
GET https://skaffa.salesys.se/api/offers/offer-information-v1/status-links?offerIds=<id> // post-sign redirect
status-links → [{offerId, statusLinks: {signed: {url, linkText, isAutomaticRedirect, ...}, signedRevisit, defaultUrl}}] — after signing navigate to signed.url, or defaultUrl when it is null. QR: render data from the qr endpoint as a QR code (any QR lib via script tag), refresh every second.
Same-device BankID open: iOS https://app.bankid.com/?autostarttoken=<t>&redirect=<r>, Android bankid:///?autostarttoken=<t>&redirect=<r>.
Formulärmallar (web form presets)
Reusable form templates, same idea as vymallar (view presets): the preset holds the steps/code, each customer form points at it and only stores its own settings.
// /api/offers/web-form-presets-v1 (GET list, POST, GET/PUT :presetId, PUT ordering)
{
"name": "...", "description": "", "iconName?": "...",
"steps": [ /* same WebFormStep[] as a web form */ ],
"country": "se",
"settingsHtml": "<handlebars settings UI>", // inställningsvy shown when configuring a form from the preset
"entityGroupIds": [], // entity groups, as for view presets
"isSupplementary": false, // likely = kompletteringsformulär (filled in on avtal.salesys.se)
"isRemoved": false
}
A web form created from a preset has presetId + presetState (its saved settings). Inside the preset's steps read them via window.form.settings, so one preset serves many customers with different products/texts/field ids. The settings UI works like a vy's inställningsvy (see the salesys-vyer skill: window.form.settings / setSettings, entity groups).
Patterns from real forms
Fully custom step: build your own UI in a custom section, hide the built-in button and drive navigation yourself.
<div class="form-field">
<label>Fullständigt namn *</label>
<!-- <Input>/<Checkbox> are built-in components; `key` keeps them stable across re-renders -->
<Input key="fullName" fieldId="<nameFieldId>" placeholder="För- och efternamn"
oninput="window.form.setField('<nameFieldId>', event.target.value)">
</div>
<Checkbox key="terms" onchange="window.form.setState({termsAccepted: !window.form.custom.termsAccepted})"
{{#if custom.termsAccepted}}checked{{/if}}></Checkbox>
<button {{#unless custom.formValid}}disabled{{/unless}} onclick="window.form.next()">Nästa steg</button>
<!-- last step: onclick="window.form.internal.submit()" -->
<script>
(function () {
window.form.hideNavigateNext("hidenext");
const ids = { fullName: '<nameFieldId>', phone: '<phoneFieldId>' };
function update() {
const v = Object.fromEntries(Object.entries(ids).map(([k, id]) => [k, window.form.input.fields[id] || '']));
const formValid = !!(v.fullName.trim() && v.phone.trim());
// compare before setState, or hooks loop forever
if (window.form.custom.formValid !== formValid) window.form.setState({ formValid });
}
update();
Object.values(ids).forEach(id => window.form.addFieldListener(id, update)); // fires when that field changes
window.form.addHook('afterFieldsPopulated', update); // after prefilled/URL fields land
})();
</script>
window.form.addFieldListener(fieldId, fn)- callback when one field changes.- Hook names seen in use:
afterFieldsPopulated(fields prefilled, e.g. from URL/query) andsetProduct(use it to auto-pick a product from a field value: bail out ifwindow.form.input.productis already set). window.form.internal.resources.products- every product the form can sell ({id, name, ...}), e.g.resources.products.find(p => p.name === value + "GB").input.fieldsis keyed by fieldId in most examples, but some real code treats it as an array (input.fields.find(f => f.fieldId === id)). Check in the console before relying on one shape.- Don't call
setFields([])with an empty array (validation error); only call it when there is something to set. - Guard optional APIs (
window.form.addFieldListener && ...) if the code must run in older form versions.
Debugging
In Chrome DevTools, switch the console context (the "top" dropdown) to the form's iframe, then inspect window.form / window.form.input / window.form.custom directly.
⚠️ Gotchas
- Hook infinite loops: inside
addHookcallbacks, always compare the current value before callingsetField/setProduct*— setters re-trigger hooks. setUser/ submituserIdsilently fails unless the form hasallowUserId: true(and, if set, the user is inallowUserTeamIds).setTemplate/ submittemplateIdmust reference a template already in the form'stemplateIds.- Use
test: true(embed option) orisTest: true(submit) during development — offer lists default toisTest=falseso test offers won't pollute production views, but you must queryisTest=any|trueto see them. hideNavigateNext/showNavigateNextreasons are stacked — ahide('a')is only undone byshow('a').setStatedoes not set fields or products — use the dedicated setters.returnUrllink placeholders accept three spellings:[[länk-id]],[[link-id]],[[link id]].- Duplicate submissions return the existing offer (
isNew: false) — passallowCreateDuplicate: trueif repeat offers are intended. getDatasections andbankIdSignArgsUrlcall your external URLs;bankIdSignArgsUrlreceives the full decrypted offer via POST — treat that endpoint as sensitive.- Auto-sign (
automaticSign) uses the one-click method; for BankID signing keep auto-sign off and run the BankID flow above after submit.