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
- Go to the Custom Data Mappings list.
- Create one Custom Data Mapping per field you need to read, using the values below. Type the object and field names in lowercase.
- 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.
- Build the create-and-update Flow and the delete Flow on
Citizen_Bill__cexactly as described in Sync custom objects using flows. - In the create-and-update Flow, keep the decision on
ISNEW()so that a new record calls StoreConnect: Sync Record Changes with Change TypeCreateand an existing record calls it withUpdate. Later steps in this article extend that decision. - 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 undercustom_data, which is the longer equivalent. sfidcarries the Salesforce record Id.createddateandsystemmodstamparrive 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 }}.
| Reference | Type | Due | Amount |
|---|---|---|---|
| {{ 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.
- Go to the POS Layouts list.
- Create a POS Layout for
contactwith the values below. The layout registers the columns whether or not it is displayed, so any identifier works; the example usesresident_lookup. - 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
- Go to the Custom Data Mappings list.
- Create one Custom Data Mapping per field, with Object API Name
contact, Access Levelread, 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.
- 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.
- 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?
Thanks for your feedback! It helps us improve our docs.