Skip to content
Log in

Surface a custom object on the website and in the POS

On this page

Overview

Use this process to show records of your own custom object, related to a Contact, on your storefront and in the POS. It covers the Salesforce setup both surfaces share, then the website and the POS in turn. The two surfaces read custom data by different mechanisms, so a design that works on one does not transfer to the other.

The worked example is a city or county that holds resident account data in Salesforce (utility bills, property tax, parking fines) and needs it in two places:

  • On the website, so a resident can sign in, see what they owe and pay it themselves.
  • In the POS, so a counter clerk can look up a resident by their bill number, see the same balances, and take payment in person.

The example builds both from one custom object, Citizen_Bill__c, with a Contact__c lookup to the resident. Substitute your own object and field names.

  Website POS
Where the data lives Your store’s database A local database on the device
How Liquid reads it {% query %} against the object JavaScript reading the device’s IndexedDB
Can it read a custom object directly? Yes No, see Part 3
What makes a field available A Custom Data Mapping A Custom Data Mapping and a POS Layout Field
When new data appears Next request Next device resync

:::warning A POS view cannot run {% query %}. The device holds a fixed set of columns per object and works offline from its own local database, so it cannot reach out for arbitrary records. Part 3 covers what to do instead. :::

Before you start

  • You have permission to create custom fields, Custom Data Mapping records, POS Layout records, and record-triggered Flows.
  • The StoreConnect sync user has read access to every object and field involved, through its profile or a permission set. A field the sync user cannot see behaves exactly like a field that was never mapped: the value never reaches the storefront or the device, and nothing reports an error.
  • You know how custom data mappings expose a field to Liquid, and how record-triggered Flows sync a custom object.
  • For Part 3, you have a POS register you can open and resync, and you know how POS layouts work.
  • You have a storefront template you can edit for the verification steps.

Part 1: The Salesforce foundation

Both surfaces depend on this, so do it once and confirm it before building either.

Step 1: Map the fields

  1. Go to the Custom Data Mappings list.
  2. Create one Custom Data Mapping per field you need to read, using the values below. Type the object and field names in lowercase.
  3. Save. Data Type is filled in for you from the Salesforce field type.
Field Value
Object API Name citizen_bill__c
Field API Name amount_due__c
Access Level read

The worked example uses six mappings: amount_due__c, bill_type__c, contact__c, due_date__c, reference__c and status__c.

Object names resolve case-insensitively, so lowercase is not strictly required here. It is required on POS Layout Fields in Part 3, and keeping one convention avoids a mismatch later.

:::tip Name needs no mapping. It arrives as a first-class attribute on every row, so {{ bill.name }} works with no mapping for it. Map only your own fields. :::

Step 2: Build the sync Flows

A Custom Data Mapping registers the schema and moves no data. Two record-triggered Flows do the moving, and without them every query returns zero rows with no error anywhere.

  1. Build the create-and-update Flow and the delete Flow on Citizen_Bill__c exactly as described in Sync custom objects using flows.
  2. In the create-and-update Flow, keep the decision on ISNEW() so that a new record calls StoreConnect: Sync Record Changes with Change Type Create and an existing record calls it with Update. Later steps in this article extend that decision.
  3. Activate both Flows before you create any test records.

:::warning An Update for a row your store has never received does not apply, and the recovery is not immediate. The store cannot find the row, so instead of writing it the store requests a full refresh of that record from Salesforce. The row does arrive eventually, but by way of a round trip out to Salesforce and back rather than a local retry, so it is not there when the update returns. Repeated updates queue repeated refresh requests, which are not deduplicated.

That is why the decision matters rather than always sending Update, and why records created before you activated the Flow are not reliably fixed by editing and saving them. Force the create branch instead, or recreate the records. :::

Step 3: Decide how you will handle undelete

There is no third Flow, because a record-triggered Flow cannot fire on undelete. The platform offers only three record trigger types, RecordBeforeSave, RecordAfterSave and RecordBeforeDelete, and the record trigger types are Create, Update, CreateAndUpdate and Delete. Undelete appears in neither list.

Delete a bill and the delete Flow removes it from your store. Restore it from the Recycle Bin and nothing tells your store it is back, so it stays missing from the website and the POS. Editing and saving the restored record does not fix it: that fires the update branch, and an update for a row the store no longer has only queues a refresh request.

The store also refuses to re-insert a record it has seen deleted. It keeps a record of every delete it has processed for a retention period, 7 days by default, and skips the insert of any record whose Id matches one of them, with no error and no log entry. Salesforce restores a record under its original Id, so a restored record is refused until that period passes. The Recycle Bin holds a deleted record for 15 days, so for the first week a restored record cannot be synced under its own Id. The retention period is a StoreConnect setting for your store, not a setting in your Salesforce org, so confirm it with StoreConnect support before relying on the window.

:::warning There is no way around the refusal from the Salesforce side. Requesting a refresh reaches the same insert path, and editing the record produces an update that finds no row and asks for a refresh. Sending Create again does not help. :::

Recreate the record instead. A new record gets a new Id, so it does not match the recorded delete and it inserts normally. Where the restored record has to keep its Id for Salesforce reasons, the store copy is unavailable until the retention period passes, so tell the people affected rather than leaving them to discover it.

The create path does work once the retention period has passed, or on a record whose delete never synced. There are two ways to send it.

With Apex: an after undelete trigger that calls the StoreConnect: Sync Record Changes action with Change Type Create.

Without Apex: add a checkbox to the object, for example Force_Store_Sync__c, and extend the decision in the create-and-update Flow so the Create outcome is taken when the record is new or the checkbox is set:

Outcome condition Change Type
ISNEW() is true, or Force_Store_Sync__c is true Create
Otherwise Update

An admin then ticks the box and saves, which fires the after-save Flow on update and takes the Create outcome. Clear the checkbox afterwards.

:::note StoreConnect’s own managed objects are not a model here. The package does not sync an undelete either, so there is no built-in pattern to copy. :::

:::tip Records created before you activated the create-and-update Flow are a different and simpler case. They were never sent and were never deleted, so no guard applies. The create path works on them, via the flag or by recreating them. :::

Step 4: Confirm the rows arrived

Before writing real template logic, prove the data is there. Add this temporarily to any storefront template:

```liquid

{%- query ‘citizen_bill__c’ as bills -%} COUNT: {{ bills.size }} {%- for bill in bills limit: 1 -%} {{ bill | json }} {%- endfor -%} ```

A count of zero means the Flows are not firing, so go back to Step 2. A count matching Salesforce means the sync works.

The json output shows the row shape:

```json

{“citizen_bill__c”: { “name”: “BILL-00010”, “sfid”: “a4tQE00000ifCzxYAE”, “object_name”: “citizen_bill__c”, “createddate”: null, “systemmodstamp”: null, “custom_data”: { “reference__c”: “ELEC-2026-08-BJ”, “bill_type__c”: “Electric Bill”, “amount_due__c”: “58.00”, “due_date__c”: “2026-09-15”, “status__c”: “Open”, “contact__c”: “003QE000006RFCQYA4” }, “reference__c”: “ELEC-2026-08-BJ”, “bill_type__c”: “Electric Bill”, “amount_due__c”: “58.00”, “due_date__c”: “2026-09-15”, “status__c”: “Open”, “contact__c”: “003QE000006RFCQYA4” }} ```

Three things to take from it:

  • Your fields sit directly on the row, so {{ bill.amount_due__c }} is the accessor to use. They also appear under custom_data, which is the longer equivalent.
  • sfid carries the Salesforce record Id.
  • createddate and systemmodstamp arrive empty. Do not build on them.

:::warning bill.data.amount_due__c renders blank. data is the accessor for custom fields on StoreConnect’s own objects such as Contact and Product2. A custom object you sync yourself uses custom_data, or the field name directly. A missing attribute on a Drop renders with no error text, so this reads exactly like a sync that never happened and sends you back to the Flows for nothing. :::

Part 2: The website

Which query conditions the object supports

query adds no scope of its own, and whether a condition works depends on the Data Type of the field’s Custom Data Mapping, not on whether the field is a lookup. The mapping’s data type mirrors the Salesforce display type, and only some of those types are implemented as query conditions.

  Data types
Filterable string, email, textarea, url, phone, reference (lookup), integer, double, boolean, date, datetime, time
Raises picklist, multipicklist, currency, percent, long, combobox, address, location, base64, anytype, encryptedstring, datacategorygroupreference, id

The first five of the unsupported types are the ones you are likely to hit. A picklist status field and a currency amount are both common on a billing object, and neither can be filtered in the query.

:::warning The error you get is misleading. An unsupported type raises Liquid error (line N): internal, which names neither the field nor the reason. The underlying message identifies the value rather than the type, so the page gives you nothing to work from. If a condition raises internal, check the mapping’s Data Type against the table above before looking anywhere else. :::

Measured on a citizen_bill__c with six read mappings, on platform 21.7.0.1:

What you write Result
{%- query 'citizen_bill__c' as bills -%} Returns every synced row
contact__c: '003QE000006RFCQYA4' Filters correctly. The lookup field works as a bare condition, and holds the Salesforce Id
contact__c: current_customer.id Matches nothing. The lookup column holds the Salesforce Id, and a drop’s id is the storefront’s own UUID
status__c: 'Open' Raised Liquid error (line N): internal, because a picklist is not supported
data.status__c: 'Open' Raised the same error, and the prefix is not the documented form here
due_date__c: '2026-09-01' Filters correctly, because a date is supported

order by on a custom object accepts only the row’s built-in columns, and of those only name sorts usefully, because createddate and systemmodstamp arrive empty. A mapped custom field is not a column, so order by 'due_date__c asc' raises Invalid order clause: unknown field. Sort in Liquid instead.

Filter by the signed-in contact

The lookup column holds the Salesforce Id, and the Contact drop does not expose one: current_customer provides id, which is the storefront’s own UUID. So the signed-in contact cannot be matched against the lookup directly.

Add a formula field on the child object surfacing the parent’s s_c__sC_Id__c, StoreConnect’s own external Id, which equals the drop’s id. The examples below call it contact_scid__c. Give it a Custom Data Mapping of its own, the same as any other field you query, then filter current_customer.id against it.

This is the approach to use. It needs no Salesforce Ids in the template, and it relies only on documented drop attributes.

:::note A formula field needs its own Custom Data Mapping before it can be queried, or the tag raises Invalid liquid query field. The custom data fields article advises against mapping formula fields because their values can change. This one is safe: a parent’s s_c__sC_Id__c never changes, so the formula result is stable. :::

A resident paying their own bills

Query on the Contact lookup, then filter and total in Liquid.

```liquid

{%- comment -%} current_customer is nil for an anonymous visitor. Without this guard the condition filters on an empty value, and every row in the store becomes a candidate – one resident’s bills shown to another. {%- endcomment -%} {%- unless current_customer -%}

Please sign in to see your account.

{%- else -%}

{%- query ‘citizen_bill__c’ as bills, contact_scid__c: current_customer.id -%}

{%- assign owed = 0 -%} {%- assign open_count = 0 -%} {%- for bill in bills -%} {%- if bill.status__c == ‘Open’ -%} {%- assign owed = owed | plus: bill.amount_due__c -%} {%- assign open_count = open_count | plus: 1 -%} {%- endif -%} {%- endfor -%}

{%- if open_count > 0 -%}

You have {{ open_count }} outstanding {%- if open_count == 1 %} bill{% else %} bills{% endif %}, totaling {{ owed | money }}.

{%- for bill in bills -%} {%- if bill.status__c == 'Open' -%} {%- endif -%} {%- endfor -%}
ReferenceTypeDueAmount
{{ bill.reference__c }} {{ bill.bill_type__c }} {{ bill.due_date__c | date: '%d %b %Y' }} {{ bill.amount_due__c | money }}

{%- else -%}

You have no outstanding bills.

{%- endif -%}

{%- endunless -%} ```

A signed-in resident with open bills now sees the count, the total owed, and one row per open bill. A resident with none sees the “no outstanding bills” message, and an anonymous visitor sees the sign-in prompt.

current_customer is itself a Contact drop, so there is no separate customer object and no current_customer.contact.

The two Ids are different values, and neither is interchangeable. current_customer.id returns StoreConnect’s own external Id, an sc_id UUID, not an 18-character Salesforce Id. A synced custom object’s lookup column holds the Salesforce Id:

current_customer.id = 1d3c61c7-ef9c-4868-8848-27389692b531 contact__c column value = 003QE000006RFCQYA4

Filtering the lookup with current_customer.id therefore matches nothing, and because an empty result is a legitimate answer the page renders “no outstanding bills” for every resident rather than reporting a problem. Use the formula field described above, which compares like with like.

:::warning There is no sfid on the Contact drop, and reaching for one fails loudly rather than quietly. Templates render with strict variables, so current_customer.sfid raises Liquid error (line N): undefined method sfid at that point and the assignment on that line never happens, leaving the query variable empty and any loop below it iterating nothing. A queried Contact record does expose sfid, because a record read through query carries its own columns:

```liquid

{%- query ‘contact’ as me, s_c__sc_id__c: current_customer.id -%} {%- for c in me -%}{{ c.sfid }}{%- endfor -%} ```

That works, but it costs a second query on every render. The formula field above is the better route. :::

plus coerces the stored string to a number, so no cast is needed before adding amount_due__c values.

:::warning The Contact condition is the only thing scoping this query to one person. query applies no store or customer scope of its own, so a missing or empty condition returns other residents’ rows. Guard the anonymous case, as above, and never widen the query to make it return something while debugging. If your object also carries a store field, add that condition too. :::

:::tip Every query tag is a database call. Run one query for the resident’s rows and reuse the result, as above, rather than querying again inside the loop. :::

Part 3: The POS

Why the website approach does not transfer

A POS device works offline from a local database and holds a fixed set of columns per object, so a POS view cannot run a query and cannot reach a custom object’s rows the way a storefront template can.

Records of a custom object reach a device only through a POS Layout for that object, with a POS Layout Field per column, picked up on the next resync.

:::warning A POS Layout whose identifier is not one of the system layout identifiers is not placed on a POS screen by itself. Its records sync to the device, but they display only when an action item opens the layout with nav:layout or open:layout. :::

The pattern that works: project onto Contact

Contact is already synced to every device. So instead of trying to get Citizen_Bill__c onto a POS screen, put the numbers a clerk needs onto the resident’s Contact record, and let the POS read them there.

For the city example, seven custom fields on Contact:

Field Type Purpose
bill_customer_number__c Text What the resident quotes at the counter
outstanding_balance__c Currency Total owed, for the summary line
property_tax_balance__c Currency Balance by category
electric_bill_balance__c Currency Balance by category
water_bill_balance__c Currency Balance by category
parking_fines_balance__c Currency Balance by category
bills_json__c Long Text Area Itemized bills as JSON, so each keeps its own reference and due date

Keep these in step with Citizen_Bill__c using a record-triggered Flow on the custom object that rolls the balances up onto the parent Contact. The custom object stays the system of record; the Contact fields are a projection of it for the counter.

:::tip The JSON field is what makes this scale. A device column per bill is not possible, so scalar columns carry the summary a clerk scans, and one Long Text Area carries the itemized detail to parse in the view. Note a Long Text Area cannot be filtered in SOQL, so never make it the field you search on. :::

Step 1: Create the POS Layout and its fields, in this order

Create the layout and its fields before you create the Custom Data Mappings.

  1. Go to the POS Layouts list.
  2. Create a POS Layout for contact with the values below. The layout registers the columns whether or not it is displayed, so any identifier works; the example uses resident_lookup.
  3. On the layout, create one POS Layout Field per column, with the Field Name in lowercase: bill_customer_number__c, outstanding_balance__c, property_tax_balance__c, electric_bill_balance__c, water_bill_balance__c, parking_fines_balance__c, bills_json__c.
Field Value
Identifier resident_lookup
Object Name contact
Type list

:::warning The order matters. A Custom Data Mapping alone does not put a standard-object custom field on a device. The device fetches an extra column only when a POS Layout references it, and creating the Custom Data Mapping is what triggers the repoll that fetches it. Create the mapping first and the repoll has already run without the column, leaving the field permanently blank. Editing the mapping does not re-trigger it, so it has to be deleted and recreated. :::

:::warning Lowercase matters here. A capitalized POS Layout Field name renders a column with a correct header and a permanently empty value. :::

Step 2: Add the Custom Data Mappings

  1. Go to the Custom Data Mappings list.
  2. Create one Custom Data Mapping per field, with Object API Name contact, Access Level read, and the Field API Name in the same lowercase form as the layout field.

Creating these triggers the repoll that brings the columns down.

:::warning Enter the Field API Name in lowercase, even though Salesforce displays it capitalized. Layout field names are lowercased for you. This one is not: it is stored exactly as typed, and the usual mistake is pasting the API name straight out of Salesforce.

A mapping saved as Bill_Customer_Number__c does not line up with the lowercased column the device builds, and the field is then left out of the payload altogether rather than arriving empty. Nothing reports it. The layout is right, the mapping is right, and the field simply never appears on the register.

If a mapped field never reaches a device, check this before anything else. :::

Step 3: Resync the device

The columns appear on the next resync, not immediately.

  1. On the register, run a full Clear & Resync (see POS storage, sync, and device administration). A delta sync does not pick up a field that was not in the schema it last synced against.
  2. Confirm what arrived by reading the device’s database rather than by testing template syntax. See Verify custom data is available in Liquid.

The seven columns now appear on every Contact record in the device’s contact store.

Step 4: Read the data in a POS view

A POS view reads the device’s local database directly. The database is named storeconnect and the object store is named after the object.

:::note storeconnect is the default and is what you will see on a normal register. The name gains a suffix, storeconnect__<storage_key>, only when the register URL carries a storage_key parameter, which is an opt-in for running more than one register in a single browser profile. Nothing in the application adds it for you. :::

```js

function openDb() { return new Promise(function (resolve, reject) { var req = indexedDB.open(‘storeconnect’); req.onsuccess = function () { resolve(req.result); }; req.onerror = function () { reject(req.error); }; }); }

// Device keys are lowercase (see the warning below this example). Lowercase // the name you look up rather than building a capitalized variant. function readField(rec, base) { var want = base.toLowerCase(); var keys = Object.keys(rec); for (var i = 0; i < keys.length; i++) { if (keys[i].toLowerCase() === want) { var v = rec[keys[i]]; if (v !== undefined && v !== null) return v; } } return null; }

// Key PRESENCE, not value. A resident who owes nothing and a column that has // not reached the device both read as null, and telling a clerk “no bills due” // when the truth is “not synced yet” is the worse of the two errors. function hasField(rec, base) { var want = base.toLowerCase(); return Object.keys(rec).some(function (k) { return k.toLowerCase() === want; }); }

function findByBillNumber(query) { return openDb().then(function (db) { return new Promise(function (resolve, reject) { var req = db.transaction(‘contact’, ‘readonly’).objectStore(‘contact’).getAll(); req.onsuccess = function () { db.close(); resolve(req.result.filter(function (c) { var bill = String(readField(c, ‘bill_customer_number__c’) || ‘’) .replace(/\D/g, ‘’); return bill && bill === query; })); }; req.onerror = function () { db.close(); reject(req.error); }; }); }); } ```

:::warning Every key on the device is lowercase, so lowercase the name you look up. The server lowercases object and field names when it builds the device schema, and the field list it sends is derived from that lowercased set, so a record’s properties come through lowercase whatever the capitalization in Salesforce. bill_customer_number__c is the key, and there is no capitalized variant to find.

Never build a candidate key by capitalizing a field name. It cannot match, and it fails looking exactly like missing data. Read the keys off the record, or lowercase the field name before comparing. :::

:::note Casing does matter one layer earlier, on the Field API Name of the Custom Data Mapping, which is the one value stored exactly as an administrator typed it. That is a different problem with a different symptom, covered in Add the Custom Data Mappings: it stops the field reaching the device at all, rather than changing the key it arrives under. :::

:::note Read the whole store with getAll() and filter in JavaScript. Do not design around device-side filtering. :::

Step 5: Let the clerk take the payment

Two POS actions complete the counter flow. Attach the resident to the cart, then add a line for the amount they are paying:

```js

// Attach the resident, so the payment is recorded against them. scAction(‘cart:add_contact’, { contact_sc_id: contact.s_c__sc_id__c });

// Add the bill as a line at the amount owed. The product is a variable-priced // payment product, one per revenue category, so the money lands in the right // place in reporting. scAction(‘cart:add_product’, { product_code: ‘city-payment-electric’, quantity: 1, unit_price: 58.00, name: ‘Electric Bill - ELEC-2026-08-BJ’ }); ```

The clerk then takes payment at checkout like any other sale.

:::warning Do not call modal:close after cart:add_product. Adding the product can open the platform’s own prompt on top of your view, and modal:close closes the topmost modal. Closing on a timer dismisses that prompt a moment after it appears, which reads to the clerk as the window reverting on its own.

Supplying unit_price suppresses the variable-price prompt, but only that one. A product with variants, a rental product, or a voucher product that uses assets opens a prompt regardless of the price you pass, so do not assume a priced line adds silently. Use a plain, variable-priced payment product with no variants for this flow. :::

:::note Adding a payment line does not mark the source bill as paid. Writing the payment back to Citizen_Bill__c, and to the billing system behind it, is integration work, such as a Flow on the resulting Order, or middleware. Decide where that belongs before you go live. :::

Configuration summary

Setting Where Website POS
Custom Data Mapping on the custom object Custom Data Mapping Required Not sufficient on its own
Sync Flows on the custom object Two record-triggered Flows Required Required
Projection fields on Contact Custom fields plus a roll-up Flow Not needed Required
POS Layout for contact POS Layout, type list Not needed Required
POS Layout Field per column POS Layout Field, lowercase, created before the mapping Not needed Required
Field-level security for the Sync user Profile or permission set Required Required

What each surface cannot do

  • The website cannot filter on a picklist, currency, percent, or multi-select picklist field, whatever the mapping. Text, number, date, boolean and lookup fields all filter. Where a field’s type is unsupported, filter on one that is and narrow the rest in Liquid.
  • The POS cannot query a custom object at all. It reads projected columns from its own local database, and only after a resync.
  • Neither writes back to the source bill. Marking a bill paid is integration work.

Was this article helpful?

Was this article helpful?