SaleSys Skills registry json
← all skills

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-webbformular

Installs into ~/.claude/skills/salesys-webbformular. Add --project to install into the current repo's .claude/skills.

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>

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