---
title: "Surface a custom object on the website and in the POS"
source: https://support.storeconnect.com/articles/surface-custom-object-in-liquid
type: article
format: markdown
site: StoreConnect Support — product and developer documentation for StoreConnect
site_index: https://storeconnect.com/llms.txt
docs_index: https://support.storeconnect.com/llms.txt
note: Append .md to any page or article URL on this site to get its Markdown form.
---
# Surface a custom object on the website and in the POS

## 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](how-to-create-a-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](liquid-custom-data-fields) expose a field to Liquid, and how [record-triggered Flows sync a custom object](sync-custom-objects-using-flows).
- For Part 3, you have a POS register you can open and resync, and you know how [POS layouts](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](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](liquid-custom-data-fields) 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 -%}
  <p>Please sign in to see your account.</p>
{%- 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 -%}
  <p>You have {{ open_count }} outstanding
    {%- if open_count == 1 %} bill{% else %} bills{% endif %},
    totaling {{ owed | money }}.</p>

  <table>
    <thead>
      <tr><th>Reference</th><th>Type</th><th>Due</th><th>Amount</th></tr>
    </thead>
    <tbody>
      {%- for bill in bills -%}
        {%- if bill.status__c == 'Open' -%}
          <tr>
            <td>{{ bill.reference__c }}</td>
            <td>{{ bill.bill_type__c }}</td>
            <td>{{ bill.due_date__c | date: '%d %b %Y' }}</td>
            <td>{{ bill.amount_due__c | money }}</td>
          </tr>
        {%- endif -%}
      {%- endfor -%}
    </tbody>
  </table>
{%- else -%}
  <p>You have no outstanding bills.</p>
{%- 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](pos-layouts) 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](pos-storage-sync-and-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](verify-custom-data-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](#step-2-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.

---

## Follow StoreConnect

- [Email Newsletter](https://storeconnect.com/c/lp-newsletter)
- [LinkedIn Newsletter](https://www.linkedin.com/build-relation/newsletter-follow?entityUrn=7444956928444862464)
- [YouTube](https://www.youtube.com/channel/UCngKdP2x8l1wcbAKW3tvU8g)
- [LinkedIn](https://www.linkedin.com/company/storeconnect)
- [X / Twitter](https://x.com/storeconnecthq)

## Popular Links

- [Partners](https://storeconnect.com/partners)
- [Become a Partner](https://storeconnect.com/become-a-partner)
- [News](https://storeconnect.com/articles/news)
- [Events](https://storeconnect.com/articles/events)
- [Live Events](https://storeconnect.com/live-events)
- [Feature Comparison](https://storeconnect.com/how-we-compare)
- [Download a free trial](https://appexchange.salesforce.com/appxListingDetail?listingId=a0N3A00000FMkeKUAT)
- [Book a Demo](https://storeconnect.com/contact)

## Documentation

- [Help documentation](https://support.storeconnect.com/help-documentation)
- [AI agents](https://support.storeconnect.com/ai)
- [Videos & tutorials](https://support.storeconnect.com/videos-tutorials)
- [Developer reference](https://support.storeconnect.com/developer-reference)
- [Release notes](https://support.storeconnect.com/release-notes)
- [Troubleshooting](https://support.storeconnect.com/troubleshooting)
- [Trust Center](https://trust.getstoreconnect.com/)
- [Status Page](https://status.storeconnect.com/)

## Contact

- info@getstoreconnect.com
- US +1 415 745 3230
- AUS +61 2 8365 2308

100 S Ashley Dr, Suite 600-2461
Tampa FL 33602-600 USA

Level 22, Sydney Place
180 George Street
Sydney, NSW, 2000, AUS

## Machine-readable

- [Site index for agents](https://storeconnect.com/llms.txt): curated map of the StoreConnect site in llms.txt format
- [Documentation index for agents](https://support.storeconnect.com/llms.txt): full technical and product documentation map

Every page and article on this site has a Markdown rendering: append `.md` to its URL.

Continue in Markdown: [Help documentation](https://support.storeconnect.com/help-documentation.md) · [Developer reference](https://support.storeconnect.com/developer-reference.md) · [Videos & tutorials](https://support.storeconnect.com/videos-tutorials.md) · [Release notes](https://support.storeconnect.com/release-notes.md)

---

StoreConnect Support — https://support.storeconnect.com/articles/surface-custom-object-in-liquid