---
title: "Build a customer portal with account-scoped data"
source: https://support.storeconnect.com/articles/customer-portal-data-access
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.
---
# Build a customer portal with account-scoped data

Use this process to build a portal where several people from one organization work on the same records: such as a B2B account where three buyers share one company's service requests, or a donor portal where a coordinator reviews everything their organization has submitted. This topic is for developers working in your store's Liquid theme.

Implementation means you can scope records on your own Salesforce objects to the signed-in customer's organization, give one contact an organization-wide view, and let a customer edit a record without being able to reach anyone else's.

## Decide where the data comes from

Work this out before you write a template, because only one of the two sources is scoped for you.

| What you are showing | Use | Who scopes it |
|----------------------|-----|---------------|
| Orders, carts, subscriptions, fulfillments | The `current_account` and `current_customer` collections | StoreConnect scopes these to the signed-in customer |
| Records on your own Salesforce objects | A [Liquid query](liquid-query) | You do, in every query you write |

If a built-in collection returns what you need, use it and stop here. `current_account.orders` returns the organization's orders with no filtering work on your part. The rest of this article covers the use case on the second row.

## How the signed-in scope is established

When a customer signs in, your store resolves two objects.

- `current_customer` — the **Contact** who signed in
- `current_account` — the **Account** that contact belongs to

Every contact at one organization resolves to the same `current_account`, and that shared value is what lets two colleagues see one record.

`current_account.id` returns the account's **StoreConnect External ID** (`s_c__sC_Id__c`), a 36-character value the platform maintains. It is not the 18-character Salesforce record ID that a lookup field stores. Step 1 below exists because of that difference.

## Before you start

- You have a Salesforce custom object holding the records the portal will show, with a lookup to **Account**.
- The Salesforce custom object syncs to your store through a record-triggered Flow that calls the **StoreConnect: Sync Record Changes** action. A Custom Data Mapping on its own moves no rows, and a query against an unsynced object returns zero results without raising. See [Sync custom objects using flows](sync-custom-objects-using-flows).
- You have a **Custom Data Mapping** for that object and for every field your templates read. See [Add custom data fields to your store](liquid-custom-data-fields).
- You can edit your store's theme templates and controller templates. See [Liquid controllers guide](liquid-controllers-guide).

## Scope the records to the signed-in organization

The examples use a custom object called **Service Request** (`service_request__c`), but you can substitute your own.

1.  Open and edit the Salesforce object that holds the portal's records, then add a text formula field called **Account Key** (`account_key__c`) with the formula `Account__r.s_c__sC_Id__c`. Do this first, because every later step filters on it.
2.  Open that object's **Custom Data Mapping** and map **Account Key** as read-only. A query raises `Invalid liquid query field` for any field that has no mapping.
3.  In the template that lists records, filter on **Account Key** rather than on the lookup field, and link each row on its `sfid`:

    
```liquid

    {%- query 'service_request__c' as requests,
        account_key__c: current_account.id
        order by 'createddate desc' -%}

    {%- for request in requests -%}
      <a href="/service-requests?id={{ request.sfid }}">{{ request.name }}</a>
    {%- endfor -%}
    ```


    Filtering on the lookup field itself returns nothing, because that column holds a Salesforce record ID while `current_account.id` holds the StoreConnect External ID. The mismatch produces zero rows rather than an error, so it is easy to miss.

    Link on `sfid`, not on `id`. On a record returned by `{% query %}`, `id` is the store database's own row number rather than a Salesforce identifier, and it is not stable across environments. Step 4 matches on `sfid`, so a link built on `id` returns nothing.

    Custom fields on a queried record sit at the top level, so `request.account_key__c` reads one. On a built-in drop they sit under `data` instead, which is why step 5 reads `current_customer.data.portal_admin__c`. Reaching for the wrong one renders blank, with no error to tell you which it was.

4.  Apply the same filter when you load a single record from an identifier in the address:

    
```liquid

    {%- query 'service_request__c' as matches,
        sfid: current_request.params.id,
        account_key__c: current_account.id -%}

    {%- assign service_request = matches | first -%}

    {%- unless service_request -%}
      <p>That request either does not exist or belongs to another organization.</p>
    {%- endunless -%}
    ```


    The account filter is what makes an identifier from the address bar safe to accept. Without it, editing that identifier reaches another organization's record.

    :::warning
    Do not fetch a wide set of records and hide rows while rendering. A record you fetched is in the page's data whether or not you print it. Narrow the query instead, so records the customer may not see are never loaded.
    :::

5.  (Optional) To give one person the organization-wide view and leave everyone else with only their own records, add a checkbox called **Portal Admin** (`portal_admin__c`) to the **Contact**, map it read-only, and branch on it:

    
```liquid

    {%- if current_customer.data.portal_admin__c -%}
      {%- query 'service_request__c' as requests,
          account_key__c: current_account.id -%}
    {%- else -%}
      {%- query 'service_request__c' as requests,
          account_key__c: current_account.id,
          contact_key__c: current_customer.id -%}
    {%- endif -%}
    ```


    Narrowing to one person needs a second formula field, **Contact Key** (`contact_key__c`), built the same way from the record's **Contact** lookup.

6.  Sign in to your storefront as a customer at one organization and confirm the list shows that organization's records and no others. Then change the identifier in the address bar to a record belonging to a different organization and confirm the page reports it as missing. You can [sign in as that customer](how-to-log-in-as-a-customer) without knowing their password.

Each signed-in customer now sees their own organization's service requests, and an identifier belonging to another organization returns nothing.

## Let a customer edit a record

Choose the path by what is being changed. Each row names the form the customer posts and the controller template that receives the post.

| What the customer changes | They post | You handle it in |
|---------------------------|-----------|------------------|
| Their own name, email, phone, or addresses | The [account form](account-form-reference) | Nothing. The form writes the standard fields itself |
| A custom data field on their own **Contact** | The [account form](account-form-reference) | `controllers/accounts/profiles/update.liquid` |
| A custom data field on one of your own records | A [custom form](custom-forms-feature) | `controllers/form_submission/create.liquid` |
| Anything that creates a record | A [custom form](custom-forms-feature) | A Flow or Apex acting on the submitted answer |

A content page is only ever served by `GET`, so a page controller cannot receive an edit. The two routes above are the ones a theme can post a write to, which is why an edit to your own object still starts at a custom form even though no record is being created.

The **account** form writes a customer's own standard details and is already scoped to the signed-in contact, so there is nothing for you to filter.

For a custom data field, mark the field **Read/Write** in its Custom Data Mapping, then write it from the controller template that receives the post. This example saves a note on the signed-in contact from the account form, in `controllers/accounts/profiles/update.liquid`:


```liquid

{% before %}
  {%- assign note = current_request.params.note -%}
  {% update current_customer, field: "portal_note__c", value: note %}
{% endbefore %}
```


:::warning
A role check in a template decides what the page displays and nothing more. Anyone can post to the route directly. In the controller template, confirm again that the target record belongs to the signed-in customer's account and that the customer holds the role the change requires. Never let a customer write their own role field.
:::

## What you cannot do

These are platform limits. No setting, permission or mapping changes them.

- **`{% update %}` writes custom data fields only.** Standard Salesforce fields such as `firstname`, `phone`, and `name`, and managed-package fields, cannot be written this way. Use the **account** form for a customer's own details, and a Flow or Apex for everything else.
- **A failed `{% update %}` is silent.** If the tag sits outside a controller, the mapping is read-only, or the field is not a custom data field, nothing is written and nothing appears in the page. The warning goes to the web console, so check it with [the web console](web-console) when a write seems to do nothing.
- **`{% update %}` cannot create records.** It changes one field on one record that already exists.
- **Orders, addresses, subscriptions, and carts stay scoped to the individual contact.** A colleague at the same organization cannot open or act on another person's order, whatever your template lists. An organization-wide view through `current_account.orders` is supported; acting on another contact's order is not.
- **A query is not scoped to your store either.** `{% query %}` returns records from every store in the Salesforce org, so filter by store as well when an object spans more than one.

## Limit which records reach the store at all

If only some of your **Account** and **Contact** records belong on the storefront, you can [mark the ones that sync](per-record-sync-opt-in) and leave the rest out. This is a useful outer limit in an org holding records that have nothing to do with your store.

## Example

Northwind Traders has three buyers on one **Account**. All three should see every service request Northwind has raised. Only the office manager should see the internal reference on each one, and only they can change it.

| Field | Object | Mapping | Purpose |
|-------|--------|---------|---------|
| **Account Key** (`account_key__c`) | **Service Request** | Read-only | Gives all three buyers the same list |
| **Portal Admin** (`portal_admin__c`) | **Contact** | Read-only | Checked on the office manager only |
| **Internal Reference** (`internal_reference__c`) | **Service Request** | Read/Write | The one field the controller writes |

The list template filters on `account_key__c` alone, so every buyer sees the full list. The detail template prints `{{ service_request.internal_reference__c }}` only when `current_customer.data.portal_admin__c` is true. The office manager changes it through a custom form, and `controllers/form_submission/create.liquid` re-reads `current_customer.data.portal_admin__c` before calling `{% update %}`, because the template check governed only what was displayed.

---

## 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/customer-portal-data-access