---
name: salesys-webbformular
description: >-
  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.
---

# 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

```jsonc
{
  "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()`:

```html
<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:

```js
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`, ...).

```hbs
{{#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](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.

```jsonc
// /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.

```html
<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) and `setProduct` (use it to auto-pick a product from a field value: bail out if `window.form.input.product` is 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.fields` is 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 `addHook` callbacks, always compare the current value before calling `setField`/`setProduct*` — setters re-trigger hooks.
- `setUser` / submit `userId` silently fails unless the form has `allowUserId: true` (and, if set, the user is in `allowUserTeamIds`).
- `setTemplate` / submit `templateId` must reference a template already in the form's `templateIds`.
- Use `test: true` (embed option) or `isTest: true` (submit) during development — offer lists default to `isTest=false` so test offers won't pollute production views, but you must query `isTest=any|true` to see them.
- `hideNavigateNext`/`showNavigateNext` reasons are stacked — a `hide('a')` is only undone by `show('a')`.
- `setState` does not set fields or products — use the dedicated setters.
- `returnUrl` link placeholders accept three spellings: `[[länk-id]]`, `[[link-id]]`, `[[link id]]`.
- Duplicate submissions return the existing offer (`isNew: false`) — pass `allowCreateDuplicate: true` if repeat offers are intended.
- `getData` sections and `bankIdSignArgsUrl` call **your** external URLs; `bankIdSignArgsUrl` receives 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.
