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.
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
Storefront retail. Each completed transaction at the register becomes a closed deal in HubSpot.
| Location ID | LYERMKED68FG3 |
|---|---|
| Environment | production |
| Auth | SQUARE_ACCESS_TOKEN (long-lived) |
| SDK | square@^44.0.1 (Node) |
B2B ERP. Sales reps build quotes here. Customer records live here as authoritative master.
| API base | api.stoneprofits.com |
|---|---|
| Auth | POST /v1/token/loginv1 · username + password + authorizedNumber |
| Read endpoints | POST /v2/Customers, POST /v2/Quotes, GET /v2/Customers/{id}, GET /v2/Quotes/{id} |
| Write endpoints | Not yet supported — see ask |
Real-time inbound, not mirrored from anywhere. Where every new lead first enters Stone City's universe.
| Web forms | Submissions on stonecity.com (warranty form, contact, et al.) → HubSpot contacts in real-time |
|---|---|
| Manual entry | Showroom walk-ins, phone leads — entered directly into HubSpot by team |
| Lead status enum | NEW · 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.
Field mapping · Square order → HubSpot deal
| HubSpot deal property | Source |
|---|---|
| dealname | "Square Order " + order.id |
| amount | order.totalMoney.amount / 100 |
| pipeline | 889312001 (Square POS pipeline) |
| dealstage | 1338115383 (closed-won) |
| closedate | order.closedAt |
| square_order_id | order.id (dedup key) |
| square_closed_at | order.closedAt |
| payment_method | Joined order.tenders[].type |
Contact + company association logic
- If
order.customerIdexists → fetch the Square customer record. - Try to match a HubSpot contact by email first (if Square has it), then by phone.
- If no match: create a new HubSpot contact, defaulting
hs_marketable_status=false(Square POS contacts are not marketing-opted-in). - Set
hs_lead_statusonly if blank (preserves prior tags likeSQUARE_POSfrom earlier syncs). - Associate the new deal to the contact, update
last_purchase_date, flagvip_customer=trueif amount ≥ $5,000. - If the customer has a
companyNameANDemailAddressin 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 function | img-integration-production-OrderSyncHandlerFunction-bcebxfuo |
|---|---|
| Log group | /aws/lambda/img-integration-production-OrderSyncHandlerFunction-zsezdkkx |
| Runtime | nodejs24.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.
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:
- By SPS ID —
sps_customer_id == customer.id(most reliable; previous syncs stamped this) - By name — exact match on company name
- 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 property | SPS source |
|---|---|
| name | customer.name |
| domain | parsed from customer.email |
| sps_customer_id | customer.id |
| sps_customer_code | customer.code |
| sps_customer_type | customer.type |
| sps_price_level | customer.priceLevel |
| sps_payment_terms | customer.paymentTerms |
| sps_credit_limit | customer.creditLimit |
| sps_sales_rep | customer.primarySalesRep |
| sps_is_tax_exempt | customer.isTaxExempt |
| sps_last_synced | ISO 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.
Stage mapping · SPS status → HubSpot stage
| SPS state | HubSpot stage ID | Label |
|---|---|---|
| transactionStatus = "Quoted" | 1352031151 | Quoted |
| transactionStatus = "Accepted" · stage = "won" | 1352031152 | Accepted (won) |
| stage = "lost" | 1352031153 | Lost |
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 property | SPS source |
|---|---|
| dealname | "{opportunityNumber || transactionNumber} — {customer} ({jobName})" |
| amount | quote.total |
| pipeline | 895324173 |
| dealstage | see stage mapping |
| closedate | quote.expiryDate |
| sps_quote_id | quote.id (dedup key) |
| sps_opportunity_id | quote.opportunityID |
| sps_quote_number | quote.transactionNumber |
| sps_revision_count | incremented on update |
| sps_customer_id | quote.customerID (used for company association) |
| sps_sales_rep | quote.primarySalesRep |
| sps_job_name | quote.jobName |
| sps_project_type | quote.projectTypeValue |
| sps_last_synced | ISO 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
| ID | Name | Source | Stages |
|---|---|---|---|
| 889312001 | Square POS | Order Sync | stage 1338115383 (closed-won, single stage) |
| 895324173 | Quotes | Quote Sync | Quoted (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).
| Value | Set by | Meaning |
|---|---|---|
| NEW | form submission, manual entry | Just 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_POS | Order Sync | Walked in, bought retail |
| SPS_B2B | Customer Sync | B2B customer in SPS |
Custom properties (selection)
- 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
- 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
- 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
- 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:
| Script | Purpose |
|---|---|
| setup-emails | Create marketing emails via HubSpot's /marketing/v3/emails API |
| setup-workflows | Create automation workflows via /automation/v4/flows |
| setup-warranty-form | Create the warranty-claim form on the public site |
| setup-lists | Create dynamic contact lists |
| setup-*-properties | Idempotently ensure custom deal/contact/company/product properties exist |
| tier-contractors | Score B2B companies by spend → Gold/Silver/Bronze on contractor_tier |
| daily-digest | Generate a daily activity summary report |
| backfill-contacts | One-off contact data backfill |
| rematch-quotes | Re-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:
- Lead enters HubSpot — form submission, walk-in, phone, manual. Logged as
hs_lead_status=NEWwith name, email, phone, company. - Sales rep qualifies the lead, decides to quote.
- Rep manually re-enters the customer in Stone Profits — name, billing address, ship-to address, tax status, price level, sales rep, payment terms.
- Rep builds the quote in SPS, adds line items, sends to customer.
- 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. - Next 12:00 UTC, Quote Sync pulls the new SPS quote → creates a HubSpot deal in the Quotes pipeline linked to that company.
- 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)
| Endpoint | What it unlocks |
|---|---|
| POST /v2/Customers | Create new SPS customer from JSON body (same field set as the GET response) |
| POST /v2/Quotes | Create a quote with line items in one call; returns quote ID + number + totals |
| GET /v2/Quotes | Paginated 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
| Endpoint | What it unlocks |
|---|---|
| Webhooks on quote status | Real-time accepted / declined / expired / converted events — no polling |
| POST /v2/Quotes/{id}/convert-to-order | Finalize accepted quote → sales order in one call |
Open questions sent with the ask
- Does the existing
AuthorizedNumbergrant write access, or do writes need a different token / OAuth scope? - Will POST endpoints accept an
Idempotency-Keyheader so we can safely retry on network failures? - In
/v2/Quotesline items, are products looked up byitemId(numeric) orsku(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
| Account | ID | Use |
|---|---|---|
| Master (iSimplifyMe LLC) | 024033896674 | All current client work + temporary Lambda home for img-integration |
| stonecity-prod | 507024405504 | Target account once Free → Paid upgrade completes. Today: SubscriptionRequiredException on S3/Lambda probes. |
Schedule cheat-sheet (all UTC)
| UTC | CT (CDT) | What fires |
|---|---|---|
| 11:00 | 06:00 | Order Sync (Lambda + LaunchAgent parallel) |
| 11:30 | 06:30 | Customer Sync |
| 12:00 | 07:00 | Quote Sync |
Lambda inventory
| Function | Log group suffix |
|---|---|
| img-integration-production-OrderSyncHandlerFunction-bcebxfuo | zsezdkkx |
| img-integration-production-CustomerSyncHandlerFunction-hwxszata | vatkakab |
| img-integration-production-QuoteSyncHandlerFunction-dfeeubxx | bbbkvhdr |
Monitoring (TODO)
- CloudWatch alarm on Lambda
Errorsmetric → SNS → email[email protected] - Optional: alarm on
Duration > 600s(early warning before 900s timeout) - Optional: alarm on
Invocations < 1over 24h (cron didn't fire)
Migration plan (master → stonecity-prod)
- Wait for Free → Paid upgrade on
507024405504(manual in Billing console, takes up to 24h) - Verify S3 + SSM probes return clean in stonecity-prod-sso profile
cd ~/img-integration/.worktrees/lambda-migrationAWS_PROFILE=master-sso npx sst remove --stage production(remove from master)AWS_PROFILE=stonecity-prod-sso npx sst deploy --stage production(redeploy)- Verify next-morning CRON fires cleanly in the new account
- 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/
| Path | What it does |
|---|---|
| src/index.ts | CLI dispatcher — handles sync-orders, sync-customers, sync-quotes, setup-*, etc. |
| src/lambda/sync-orders.ts | Lambda handler, 25h sinceDate window, wraps OrderSync |
| src/lambda/sync-customers.ts | Lambda handler, --b2b-only mode, wraps CustomerSync |
| src/lambda/sync-quotes.ts | Lambda handler, 25h modifiedSince window, wraps QuoteSync |
| src/sync/order-sync.ts | OrderSync class — Square orders → HubSpot deals |
| src/sync/customer-sync.ts | CustomerSync class — SPS customers → HubSpot companies |
| src/sync/quote-sync.ts | QuoteSync class — SPS quotes → HubSpot deals |
| src/sync/customer-classifier.ts | B2B vs retail vs skip classification |
| src/sync/customer-field-mapper.ts | SPS customer → HubSpot property mappings |
| src/sync/company-enrichment.ts | Square POS contact → linked HubSpot company creation |
| src/clients/square.ts | Square SDK wrapper · orders, customers, catalog |
| src/clients/hubspot.ts | HubSpot SDK wrapper · ~30 methods covering deals, contacts, companies, line items, properties, lists, workflows |
| src/clients/sps-auth.ts | POST /v1/token/loginv1 + token caching |
| src/clients/sps-customers.ts | POST /v2/Customers query, chunked fetch by salesRep |
| src/clients/sps-quotes.ts | POST /v2/Quotes query, paginated |
| src/utils/retry.ts | withRetry() — exponential backoff on HTTP 429 only |
| sst.config.ts | SST v4 config — 3 Cron Lambdas, env vars baked at deploy time |