Overview
The clients module is the customer system of record for every store under your workspace. A “client” is a person or a company — there’s no separate “company” table at the operator level — identified by an email address (case-insensitive) within the scope of a single store. Orders, payments, contracts, messages, and RUO attestations all attach to this one row.
The clients module is the reference implementation for the rest of the operator surface: every other module (orders, vendors, shipping, marketplace) copies its list-page pagination, its detail-page layout, and its CSV import flow from clients. That’s why “does it work the same as clients?” is a common question for any new feature — and the answer should almost always be “yes.”
What working correctly looks like
If everything is wired up, you should see these things. If any one of them is missing, jump to Troubleshooting.
- The list page renders >1,000 clients without scroll jank, through a virtualized list (only visible rows are mounted).
- Posting the same email to
POST /v1/clientstwice returns the original row with"duplicate": true— never a second row. - Imported clients show an imported · <source> chip next to their name on both the list and detail pages.
- The inbox at Clients → Inbox shows the most recent inbound message per client, threaded.
- Switching stores in the shell instantly hides clients from the other store — the list refetches with a new
storeIdcache key. - A soft-deleted client can be re-created with the same email without hitting a unique-constraint trap.
Create a client
- Go to Clients in the sidebar, click New client.
- Fill in at minimum a name and one of (email, phone). Address, date of birth, allergies, and notes are optional structured fields.
- (Optional) Set Type to Retail or Wholesale. This drives default pricing tier and a few UI affordances; see below.
- (Optional) Add tags (vip, net-30, research-lab).
- Click Create. The row appears at the top of the list and the detail page opens.
Empty name and malformed email are rejected client-side. Server-side validation catches the same things plus store-scoped duplicates — if a client with the exact same email already exists in this store, the form offers to Open existing rather than insert a duplicate.
Bulk import
For 10 clients, use the form. For 100+, use the CSV importer.
- From the list page, click Import in the page header.
- Download the template CSV. The required columns are
nameand one ofemail,phone; everything else is optional. Header row is required. - Drop your file. The importer parses it client-side, shows you a 10-row preview, and warns you about empty / malformed cells before submission.
- Click Import. The server runs
rpc_clients_bulk_importwhich inserts rows in a single transaction. A 100-row file takes <1s; a 10,000-row file takes <60s and does not freeze the UI (it streams progress). - When done, you get a per-row result summary: created, skipped (duplicate), or failed (with the offending column).
One bad row in a 100-row file is reported by index and the other 99 still commit. You don’t need to fix the file and start over.
Smart upsert — how dedupe works
Anything that creates a client — the operator form, the bulk importer, the public marketplace checkout, the Integration API — runs through the same three-step resolver. We never blindly insert.
-
By
external_id. If the create payload carries anexternal_id(Integration API only) and we’ve seen that id from this same integration key before, we attach to that existing row. Done. -
By email + (org, store). If no external id match, we look for a client in the same store with the same email (case-insensitive:
JANE@example.commatchesjane@example.com). If found, we attach the newexternal_idfor next time, stamp the source, and return the existing row. -
Create new. No match → brand-new client row. We stamp
source_label,source_external_id, andsource_integration_key_idso the row is traceable back to where it came from.
Same-store-only is intentional. The same person buying from Acme Peptides and from Sigma Labs — two stores under the same workspace — is two client rows, one per store. This is what operators want: a client’s order history under each brand stays under that brand.
Retail vs wholesale
{{COMPANY_SHORT_NAME}} is built for both retail (research customers buying single vials) and wholesale (labs and resellers buying by the case). The Type field on a client switches a few defaults:
- Retail — the default. Orders show retail prices. Public approval pages use the retail-customer copy. No net-terms checkbox on invoices.
- Wholesale — price columns surface wholesale tier pricing where configured. Invoices get a Terms field (Net 15, Net 30, Net 60). The CRM detail page surfaces a Tax exempt toggle and an optional resale certificate upload.
Type is per-client, not per-store, so you can have a mix in one store. Change it at any time from the detail page; existing orders don’t recalculate.
The inbox view
At Clients → Inbox is a unified message inbox: one row per client thread, sorted by most recent activity. Inbound channels today are SMS (via SendBlue) and forwarded operator-replies via email; in-portal messaging from the marketplace is queued for a follow-up.
- Open the inbox. Threads are listed left-to-right with the client name, last message preview, channel pill (
SMS/email/in_app), and unread badge. - Click a thread. Messages render oldest-first, the unread badge clears (we advance
operator_last_read_atserver-side), and the composer appears at the bottom. - Pick a reply channel:
- In-app — saved to the thread; the client sees nothing externally. Useful for internal notes.
- Email — sent through the same Resend pipeline as everything else. The thread shows delivery state (
queued→sent→delivered). - SMS — sent through SendBlue. Same delivery progression.
- Click Close thread to archive. Next inbound message from the same client opens a new thread; Re-open reverses it.
Threads are store-scoped — switching stores in the shell hides threads from the other store. Threads also respect role permissions: only members with clients:read see inbox at all.
Per-client message history
The detail page Clients → client → Messages shows every interaction with this client in one timeline: inbound SMS, outbound emails, proposal-sent events, payment receipts, attestation captures. Each row links to the originating record (the proposal, the invoice) so you can click through.
For the messaging surface itself, prefer the unified inbox view — the per-client timeline is read-only and meant for audit / context.
Tags & segments
Tags are free-form labels you attach to clients. They are stored per-store and are auto-completed from prior use.
- Add a tag from the detail page or inline from a row in the list.
- Filter the list by one or more tags with the chip picker at the top.
- Saved segments are URL-addressable (
?tags=vip,net-30). Bookmark them and pin them in your shell.
There is no formal “list” or “segment” entity in v1 — the URL is the segment. If you find yourself reusing a complex tag combination across the team, paste the URL into a shared doc; that’s the recommended pattern until saved-segments ship.
Imported source badges
Every client created through an inbound integration (a pck_int_* Integration API key) is stamped with three columns: source_label (the human name of the integration key), source_external_id (the id the source system used), and source_integration_key_id (the key uuid).
In the UI, this surfaces as a small imported · <source> chip next to the client name on both the list page and the detail page header. Hover it to see the external id; click through to Settings → Connectors to see the originating integration.
When the same client is created in {{COMPANY_SHORT_NAME}} and imported from WooCommerce, the smart upsert merges them into one row. The badge tells you the row was first seen via Woo; the timeline tells you about every later touch. You can always tell which integration originally produced the row.
RUO attestation tracking
For research-use-only (RUO) products, the marketplace requires the buyer to attest to RUO at checkout. The attestation is captured against the client row (not the order), so re-purchases re-use the attestation if it’s within the configured validity window.
- The detail page shows the latest attestation: text shown, version, captured timestamp, IP.
- Each new attestation is appended; the page shows a timeline of all captures.
- Validity window is configured at Settings → Compliance → RUO. When an attestation falls out of window, the next RUO purchase re-prompts the buyer.
Privacy & data export
{{COMPANY_SHORT_NAME}} processes client PII (name, email, phone, address, optionally DOB and allergies) on your behalf. Your obligations as the data controller, and how to fulfill a client’s data export / deletion request, are documented at /legal/data-handling.html.
The operator UI has a Export data button on every detail page that produces a JSON bundle containing the client row, all attached orders, all attached payments, all messages, and all attestations. Use it to fulfill subject access requests. The Delete action is a soft delete by default (the row is hidden but the audit history remains intact); a hard delete is available from Settings → Privacy → Erasure requests with a 7-day cool-off.
Settings & permissions
Where settings live:
- Settings → Modules — the clients module is free and enabled by default; you cannot disable it.
- Settings → Compliance → RUO — attestation text, validity window.
- Settings → Connectors — integration keys that can upsert clients; see also the Request log tab for inbound POSTs.
Roles & permissions:
clients:read— see the list and detail pages (default for Member and above).clients:write— create and edit clients.clients:delete— archive clients. Hard-delete requiresclients:erase, typically only granted to Owner.clients:export— download data-export bundles.
API + automation
Programmatic access is through the Integration API:
POST /integrations/v1/clients— upsert a client without creating an order. Most integrations don’t need this because client info insidePOST /ordersalready upserts. See POST /clients.- Smart upsert applies to API calls too. The same three-step resolver (external_id, email, create) runs for every API-created client.
- Reading clients via API is available through the Public API (read-only, separate key prefix).
Outbound, the clients module emits these events on the webhooks bus: client.created, client.updated, client.archived. Subscribe from Settings → Webhooks.
Troubleshooting
The full symptom-to-fix table lives at Troubleshooting. Common clients-specific issues:
- Duplicate client from an integration — almost always either a missing
external_idor an email mismatch (whitespace, casing). - Email to a client didn’t arrive
- SMS reply didn’t arrive
If two operators see different client lists for what should be the same store, the most likely cause is a store-switcher mismatch in their session. Confirm the active store id from the URL on both sides.
Related
- Orders & invoicing — every order attaches to a client.
- Integration API — upsert clients from any external system.
- Outbound webhooks — subscribe to
client.*events. - Buying on the marketplace — marketplace checkouts produce client rows via the same smart upsert.
- Data handling & privacy — subject access, retention, erasure.