Stone City — Data Architecture

How leads, orders, customers, and quotes flow between Square POS, Stone Profits, and HubSpot — and what happens next when the dual-entry gap closes.

Live · all syncs green v1 · 2026-05-11 Internal · noindex

01 Overview

Stone City runs three customer-facing systems: a retail storefront on Square POS, a B2B operation through the Stone Profits ERP, and a marketing & lead-capture layer on HubSpot. HubSpot is the aggregator — every paid order, every B2B customer record, and every quote-in-flight mirrors into it nightly so the team has one view of the pipeline.

order-sync · 11:00 UTC form submit · realtime customer-sync · 11:30 UTC quote-sync · 12:00 UTC manual entry manual entry Square POS storefront hardware location LYERMKED68FG3 Web forms + manual stonecity.com forms warranty, contact HubSpot CRM deals · companies · contacts pipelines 889312001 / 895324173 marketing automation · workflows Stone Profits (SPS) ERP api.stoneprofits.com customers · quotes · orders · invoices Sales team enters customer + quote in SPS
▸ Click any system above to highlight its data flows.

One-way: Square → HubSpot, SPS → HubSpot, forms → HubSpot. Nothing flows back from HubSpot today. The dotted lines from Sales team mark the dual-entry friction: a rep enters the customer twice (once in HubSpot when it arrives as a lead, again in SPS when they start a quote). Closing that gap requires Stone Profits to expose write endpoints — covered below.

02 Source systems

Square POS

Storefront retail. Each completed transaction at the register becomes a closed deal in HubSpot.

Location IDLYERMKED68FG3
Environmentproduction
AuthSQUARE_ACCESS_TOKEN (long-lived)
SDKsquare@^44.0.1 (Node)
Stone Profits (SPS)

B2B ERP. Sales reps build quotes here. Customer records live here as authoritative master.

API baseapi.stoneprofits.com
AuthPOST /v1/token/loginv1 · username + password + authorizedNumber
Read endpointsPOST /v2/Customers, POST /v2/Quotes, GET /v2/Customers/{id}, GET /v2/Quotes/{id}
Write endpointsNot yet supportedsee ask
HubSpot — lead capture (independent of sync)

Real-time inbound, not mirrored from anywhere. Where every new lead first enters Stone City's universe.

Web formsSubmissions on stonecity.com (warranty form, contact, et al.) → HubSpot contacts in real-time
Manual entryShowroom walk-ins, phone leads — entered directly into HubSpot by team
Lead status enumNEW · QUOTE · SALES_ORDER · UNQUALIFIED · SQUARE_POS · SPS_B2B

03 Order Sync — Square POS → HubSpot deals

Daily 11:00 UTC. Pulls completed Square orders from the last 25 hours, creates a HubSpot deal per order in the Square POS pipeline, links the deal to a contact (looked up by email then phone), tags VIPs on $5k+ purchases, and writes line items.

Schedule
cron(0 11 * * ? *)
Window
last 25 hours
Today's runtime
566 ms (1 order processed)
Historical (no-window)
~600 s (1467 orders dedup)
Field mapping · Square order → HubSpot deal
HubSpot deal propertySource
dealname"Square Order " + order.id
amountorder.totalMoney.amount / 100
pipeline889312001 (Square POS pipeline)
dealstage1338115383 (closed-won)
closedateorder.closedAt
square_order_idorder.id (dedup key)
square_closed_atorder.closedAt
payment_methodJoined order.tenders[].type
Contact + company association logic
  1. If order.customerId exists → fetch the Square customer record.
  2. Try to match a HubSpot contact by email first (if Square has it), then by phone.
  3. If no match: create a new HubSpot contact, defaulting hs_marketable_status=false (Square POS contacts are not marketing-opted-in).
  4. Set hs_lead_status only if blank (preserves prior tags like SQUARE_POS from earlier syncs).
  5. Associate the new deal to the contact, update last_purchase_date, flag vip_customer=true if amount ≥ $5,000.
  6. If the customer has a companyName AND emailAddress in Square, enrich/link a HubSpot company too.
Dedup + idempotency

For each order, search HubSpot for an existing deal where square_order_id == order.id. If found, skip. Otherwise create.

This means re-running the sync is safe — duplicates can't happen. With the 25h sinceDate window the work is small (typically a few orders); without the window the loop dedups every completed order ever (~1467 today, ~600s of HubSpot Search-API calls).

Infrastructure · Lambda + LaunchAgent in parallel
Lambda functionimg-integration-production-OrderSyncHandlerFunction-bcebxfuo
Log group/aws/lambda/img-integration-production-OrderSyncHandlerFunction-zsezdkkx
Runtimenodejs24.x · 1024 MB · 900 s timeout · 0 retries
Account (today)024033896674 (master)
Account (target)507024405504 (stonecity-prod)
LaunchAgent~/Library/LaunchAgents/com.img.sync-orders.plist

Today both run in parallel (Lambda at 11:00 UTC, LaunchAgent at 06:00 CT). Once 24–48 h of clean Lambda runs is confirmed, unload the LaunchAgent.

04 Customer Sync — SPS → HubSpot companies

Daily 11:30 UTC. Pulls all B2B customers from Stone Profits (~1,600 active records out of ~28k total), classifies each as B2B vs retail, then creates or updates HubSpot companies — matched first by SPS customer ID, then by name, then by domain. Linked contacts get SPS metadata stamped on them.

Schedule
cron(30 11 * * ? *)
B2B mode
enabled in Lambda
Local runtime baseline
~13 min (LaunchAgent, full set)
Fetch strategy
chunked by salesRepName
Fetch strategy · why chunked by sales rep

The SPS Customers v2 endpoint paginates at 200 records. For ~28k customers across ~140 pages, a single linear pass is slow and fragile. We instead discover the distinct salesRepName values up front, then fetch each rep's customers in parallel (with bounded concurrency). De-dup happens on the merged result by customer.id.

This is the SPS endpoint pattern the previous owner reverse-engineered with their contact at Stone Profits. The discovery probe uses fetchPage({ status: "Active" }, 1, 500) to enumerate reps before fan-out.

Classification · B2B vs retail vs skip

classifyCustomer(customer) at src/sync/customer-classifier.ts returns one of:

  • b2b — has a company name distinct from contact name, typically customer.type = "Contractor" / "Trade" / "Wholesale"
  • retail — individual buyer, no company
  • skip — generic placeholder records (e.g. "Walk-in Customer", "Cash Sale")

The Lambda runs --b2b-only mode (retail is handled by Order Sync via Square POS).

Match logic · 3-tier dedup

For each B2B customer, try to find an existing HubSpot company in this order:

  1. By SPS IDsps_customer_id == customer.id (most reliable; previous syncs stamped this)
  2. By name — exact match on company name
  3. By domain — derived from customer.email

If no match → create a new company, stamp sps_customer_id for future runs.

Field mapping · SPS customer → HubSpot company
HubSpot company propertySPS source
namecustomer.name
domainparsed from customer.email
sps_customer_idcustomer.id
sps_customer_codecustomer.code
sps_customer_typecustomer.type
sps_price_levelcustomer.priceLevel
sps_payment_termscustomer.paymentTerms
sps_credit_limitcustomer.creditLimit
sps_sales_repcustomer.primarySalesRep
sps_is_tax_exemptcustomer.isTaxExempt
sps_last_syncedISO timestamp at sync time

Linked HubSpot contacts get the same SPS metadata (sps_customer_id, sps_customer_type, sps_price_level, sps_sales_rep) plus hs_lead_status=SPS_B2B and hs_marketable_status=false.

SPS_B2B enum gotcha (resolved 2026-05-11): The HubSpot hs_lead_status property is an enumeration — values are constrained to a whitelist. SPS_B2B wasn't in the list when the field was first written by this sync, causing 1,600+ contact-link failures every morning. PATCH'd into the enum on 2026-05-11. Today's enum: NEW, QUOTE, SALES_ORDER, UNQUALIFIED, SQUARE_POS, SPS_B2B.

05 Quote Sync — SPS → HubSpot deals

Daily 12:00 UTC. Pulls SPS quotes modified in the last 25 hours, creates or updates a HubSpot deal per quote in the Quotes pipeline. Maps SPS transactionStatus / stage to HubSpot stage IDs (Quoted / Accepted / Lost). Tracks revisions.

Schedule
cron(0 12 * * ? *)
Window
last 25 hours
Pipeline
895324173
Page size
500 quotes/page
Stage mapping · SPS status → HubSpot stage
SPS stateHubSpot stage IDLabel
transactionStatus = "Quoted"1352031151Quoted
transactionStatus = "Accepted" · stage = "won"1352031152Accepted (won)
stage = "lost"1352031153Lost
Defensive: mapping checks both transactionStatus and stage because SPS's stage field is mostly null in production data.
Field mapping · SPS quote → HubSpot deal
HubSpot deal propertySPS source
dealname"{opportunityNumber || transactionNumber}{customer} ({jobName})"
amountquote.total
pipeline895324173
dealstagesee stage mapping
closedatequote.expiryDate
sps_quote_idquote.id (dedup key)
sps_opportunity_idquote.opportunityID
sps_quote_numberquote.transactionNumber
sps_revision_countincremented on update
sps_customer_idquote.customerID (used for company association)
sps_sales_repquote.primarySalesRep
sps_job_namequote.jobName
sps_project_typequote.projectTypeValue
sps_last_syncedISO timestamp at sync time

Empty strings are stripped before write — HubSpot rejects empty values on number/date properties.

Association · linking deal to company

Each quote has a customerID pointing to an SPS customer. Customer Sync runs at 11:30 UTC, 30 minutes before Quote Sync at 12:00 UTC, so by the time we process a new quote the matching HubSpot company already exists with sps_customer_id = customerID. Quote Sync looks up that company and creates a Deal–Company association alongside the deal create.

For backfill of older quotes whose companies didn't exist at sync time, there's a rematch-quotes-to-companies script in the repo (scripts/rematch-quotes-to-companies.ts).

06 HubSpot — the aggregator

HubSpot is where everything lands. Three pipelines, custom properties stamped by every sync, marketing automation triggered by lead status changes, and a reporting layer the team actually looks at.

Pipelines

IDNameSourceStages
889312001Square POSOrder Syncstage 1338115383 (closed-won, single stage)
895324173QuotesQuote SyncQuoted (1352031151) → Accepted (1352031152) → Lost (1352031153)

Lead status enum

Every contact gets an hs_lead_status tag indicating origin / state. Writes are set-if-blank — earlier tags are never overwritten (so a contact who started as NEW from a form, then became SQUARE_POS from a sale, keeps the SQUARE_POS tag when later customer-sync would otherwise tag them SPS_B2B).

ValueSet byMeaning
NEWform submission, manual entryJust arrived, not yet qualified
QUOTE(manual / workflow)A quote has been started
SALES_ORDER(manual / workflow)Closed-won, order placed
UNQUALIFIED(manual)Not a fit
SQUARE_POSOrder SyncWalked in, bought retail
SPS_B2BCustomer SyncB2B customer in SPS

Custom properties (selection)

On Deals
  • square_order_id, square_closed_at, payment_method
  • sps_quote_id, sps_quote_number, sps_opportunity_id, sps_opportunity_number
  • sps_transaction_status, sps_stage
  • sps_customer_id, sps_quote_date, sps_expiry_date
  • sps_job_name, sps_project_type, sps_sales_rep
  • sps_so_count, sps_so_numbers, sps_revision_count
  • closed_lost_reason, project_type
On Companies
  • sps_customer_id, sps_customer_code, sps_customer_type
  • sps_price_level, sps_payment_terms, sps_credit_limit
  • sps_is_tax_exempt, sps_sales_rep, sps_sales_rep_id
  • sps_parent_customer_id, sps_last_synced
  • contractor_tier (Gold/Silver/Bronze), last_order_date
On Contacts
  • sps_customer_id, sps_customer_type, sps_price_level, sps_sales_rep
  • vip_customer (flagged at $5k+ Square purchase)
  • last_purchase_date, total_lifetime_value
  • referral_code, review_requested_date
On Products
  • sps_id, sps_category, sps_subcategory, sps_type
  • sps_color, sps_finish, sps_thickness, sps_dimensions
  • price_wholesale, price_contractor
  • sps_supplier, sps_brand, sps_origin

Marketing automation + ops scripts

Beyond the three syncs, the same repo has setup-once / on-demand scripts for the rest of the HubSpot footprint. Defined in package.json · invoked manually:

ScriptPurpose
setup-emailsCreate marketing emails via HubSpot's /marketing/v3/emails API
setup-workflowsCreate automation workflows via /automation/v4/flows
setup-warranty-formCreate the warranty-claim form on the public site
setup-listsCreate dynamic contact lists
setup-*-propertiesIdempotently ensure custom deal/contact/company/product properties exist
tier-contractorsScore B2B companies by spend → Gold/Silver/Bronze on contractor_tier
daily-digestGenerate a daily activity summary report
backfill-contactsOne-off contact data backfill
rematch-quotesRe-link quote deals to companies whose sps_customer_id was set after the quote was synced

07 The dual-entry gap

The current sync architecture is one-way: everything flows into HubSpot, nothing flows out. That works for reporting and unified visibility but creates a manual handoff every time a HubSpot lead becomes an SPS quote.

Today's workflow when a new B2B lead converts to a quote:

  1. Lead enters HubSpot — form submission, walk-in, phone, manual. Logged as hs_lead_status=NEW with name, email, phone, company.
  2. Sales rep qualifies the lead, decides to quote.
  3. Rep manually re-enters the customer in Stone Profits — name, billing address, ship-to address, tax status, price level, sales rep, payment terms.
  4. Rep builds the quote in SPS, adds line items, sends to customer.
  5. Next 11:30 UTC, Customer Sync pulls the new SPS customer → matches the existing HubSpot company by name/domain → stamps sps_customer_id + other SPS metadata on the HubSpot record.
  6. Next 12:00 UTC, Quote Sync pulls the new SPS quote → creates a HubSpot deal in the Quotes pipeline linked to that company.
  7. Sales leadership sees the quote in HubSpot reporting the next morning.

Step 3 is the friction. The rep typed the customer's information into HubSpot (or it arrived via form) and now retypes it into SPS. Cost: ~5–10 minutes per new B2B customer, plus the data-drift risk if address fields differ between the two records.

Closing the gap requires SPS to expose write endpoints so HubSpot can push a "create customer + create quote" payload when the rep clicks a button in the HubSpot deal page (via a HubSpot custom card extension). The data flow becomes round-trip — see future state.

08 SPS write API ask Sent 2026-04-14

Email sent to the Stone Profits API contact on 2026-04-14 requesting six endpoints to unblock the write direction. Status: awaiting their response.

Must-haves (in priority order)

EndpointWhat it unlocks
POST /v2/CustomersCreate new SPS customer from JSON body (same field set as the GET response)
POST /v2/QuotesCreate a quote with line items in one call; returns quote ID + number + totals
GET /v2/QuotesPaginated quote listing with filters (currently quotes use POST query, GET listing missing)
PATCH /v2/Customers/{id}Partial customer updates for when a HubSpot contact's email / phone / address changes

Nice-to-haves

EndpointWhat it unlocks
Webhooks on quote statusReal-time accepted / declined / expired / converted events — no polling
POST /v2/Quotes/{id}/convert-to-orderFinalize accepted quote → sales order in one call

Open questions sent with the ask

  • Does the existing AuthorizedNumber grant write access, or do writes need a different token / OAuth scope?
  • Will POST endpoints accept an Idempotency-Key header so we can safely retry on network failures?
  • In /v2/Quotes line items, are products looked up by itemId (numeric) or sku (string)?
  • Timeline estimate? Even a rough "2 weeks / 2 months / next quarter" lets us sequence the HubSpot custom-card work.

The full email draft is in ~/img-integration/docs/HANDOFF-2026-04-14-customer-sync.md lines 443–519. The body covers payload examples for POST /v2/Quotes with line-item structure.

09 Future state (once SPS ships writes)

Once Stone Profits exposes the write endpoints above, a 4th sync direction becomes possible and the dual-entry gap closes.

What changes

  • New: HubSpot Deal → SPS Customer + Quote (push, real-time). Triggered by a HubSpot custom-card button on the deal sidebar.
  • Sales rep opens a qualified HubSpot deal, clicks "Quote in SPS", a dialog asks for line items + sales rep + expiry, POSTs to /v2/Customers (if SPS doesn't already have one) then /v2/Quotes, stamps the returned quote ID back on the HubSpot deal.
  • Existing Quote Sync becomes redundant for in-flight quotes (they're already in HubSpot from creation) but stays useful for status updates and for quotes created directly in SPS (older workflow).
  • Webhook listener (if SPS ships webhooks) replaces the daily Quote Sync polling for status changes. Latency drops from up to 24h → seconds.

Implementation footprint

  • HubSpot custom card extension (UI extension API) — small React component shown on deal page
  • Backend Lambda (or extend the existing Lambdas) handling the POST flow, idempotent via the HubSpot deal ID
  • SPS write client in src/clients/sps-customers.ts + sps-quotes.ts (currently read-only)

Time-to-build estimate once SPS endpoints exist: ~2 weeks for the basic create-customer-and-quote flow, +1 week for the webhook listener if webhooks ship.

10 Operational

AWS accounts

AccountIDUse
Master (iSimplifyMe LLC)024033896674All current client work + temporary Lambda home for img-integration
stonecity-prod507024405504Target account once Free → Paid upgrade completes. Today: SubscriptionRequiredException on S3/Lambda probes.

Schedule cheat-sheet (all UTC)

UTCCT (CDT)What fires
11:0006:00Order Sync (Lambda + LaunchAgent parallel)
11:3006:30Customer Sync
12:0007:00Quote Sync

Lambda inventory

FunctionLog group suffix
img-integration-production-OrderSyncHandlerFunction-bcebxfuozsezdkkx
img-integration-production-CustomerSyncHandlerFunction-hwxszatavatkakab
img-integration-production-QuoteSyncHandlerFunction-dfeeubxxbbbkvhdr

Monitoring (TODO)

  • CloudWatch alarm on Lambda Errors metric → SNS → email [email protected]
  • Optional: alarm on Duration > 600s (early warning before 900s timeout)
  • Optional: alarm on Invocations < 1 over 24h (cron didn't fire)

Migration plan (master → stonecity-prod)

  1. Wait for Free → Paid upgrade on 507024405504 (manual in Billing console, takes up to 24h)
  2. Verify S3 + SSM probes return clean in stonecity-prod-sso profile
  3. cd ~/img-integration/.worktrees/lambda-migration
  4. AWS_PROFILE=master-sso npx sst remove --stage production (remove from master)
  5. AWS_PROFILE=stonecity-prod-sso npx sst deploy --stage production (redeploy)
  6. Verify next-morning CRON fires cleanly in the new account
  7. Unload local LaunchAgents

Quick reference commands

# Tail order sync logs
aws logs tail /aws/lambda/img-integration-production-OrderSyncHandlerFunction-zsezdkkx \
  --profile master-sso --since 5m --follow

# Manual one-off invocation (sync)
aws lambda invoke --profile master-sso \
  --function-name img-integration-production-OrderSyncHandlerFunction-bcebxfuo \
  --invocation-type Event --payload '{}' /tmp/out.json

# Unload local LaunchAgents after Lambda confidence
launchctl unload ~/Library/LaunchAgents/com.img.sync-orders.plist
launchctl unload ~/Library/LaunchAgents/com.img.sync-customers.plist
launchctl unload ~/Library/LaunchAgents/com.img.sync-quotes.plist

# Re-run a sync locally
cd ~/img-integration && npm run sync-orders

11 Code map

Repo: ~/img-integration/

PathWhat it does
src/index.tsCLI dispatcher — handles sync-orders, sync-customers, sync-quotes, setup-*, etc.
src/lambda/sync-orders.tsLambda handler, 25h sinceDate window, wraps OrderSync
src/lambda/sync-customers.tsLambda handler, --b2b-only mode, wraps CustomerSync
src/lambda/sync-quotes.tsLambda handler, 25h modifiedSince window, wraps QuoteSync
src/sync/order-sync.tsOrderSync class — Square orders → HubSpot deals
src/sync/customer-sync.tsCustomerSync class — SPS customers → HubSpot companies
src/sync/quote-sync.tsQuoteSync class — SPS quotes → HubSpot deals
src/sync/customer-classifier.tsB2B vs retail vs skip classification
src/sync/customer-field-mapper.tsSPS customer → HubSpot property mappings
src/sync/company-enrichment.tsSquare POS contact → linked HubSpot company creation
src/clients/square.tsSquare SDK wrapper · orders, customers, catalog
src/clients/hubspot.tsHubSpot SDK wrapper · ~30 methods covering deals, contacts, companies, line items, properties, lists, workflows
src/clients/sps-auth.tsPOST /v1/token/loginv1 + token caching
src/clients/sps-customers.tsPOST /v2/Customers query, chunked fetch by salesRep
src/clients/sps-quotes.tsPOST /v2/Quotes query, paginated
src/utils/retry.tswithRetry() — exponential backoff on HTTP 429 only
sst.config.tsSST v4 config — 3 Cron Lambdas, env vars baked at deploy time