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 Phase 2 — Website rebuild + Sentinel concierge
Migrate www.stonecity.com from WordPress and products.stonecity.com from Stone Profits' IIS hosting into one unified Next.js application on AWS. Add an AI concierge (Sentinel) that helps customers navigate the 11,793-SKU catalog. Pricing stays off the public surface.
Architecture
┌──────────────────────────┐
│ stonecity.com (Next.js) │
│ CloudFront + Lambda@Edge│
└──────────────────────────┘
│ │ │
┌─────────┘ │ └──────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌────────────┐
│ S3 │ │ Sentinel │ │ HubSpot │
│ product │ ──► │ (Bedrock │ │ Forms API │
│ cache │ │ KB + │ │ (quotes, │
│ (~60MB) │ │ Claude) │ │ contact, │
└──────────┘ └──────────┘ │ trade-pro)│
▲ └────────────┘
│ nightly sync (3rd sink)
│
┌──────────────────────────────┐
│ img-integration nightly job │
│ (existing — adds new sink) │
└──────────────────────────────┘
│
▼
┌──────────┐
│ SPS │ (source of truth, read-only)
│ /v2/Prod │
└──────────┘
Decisions locked
| Decision | Choice |
|---|---|
| Framework | Next.js 14+ (App Router) — per CLAUDE.md standard |
| Hosting | SST v3 + OpenNext → CloudFront + Lambda@Edge + S3 |
| AWS account | stonecity-prod (507024405504) once Free→Paid; master temporarily |
| Commerce model | Catalog + quote-request only — no cart, no checkout, no public pricing |
| Sentinel role | On-site AI concierge — natural-language search + product recommendations |
| WP migration | Full replace — all WP pages migrate to Next.js, WordPress decommissioned at cutover |
| Product data ownership | SPS stays system of record; Stone City mirrors nightly to S3, read-only |
| Brand aesthetic | Clay-inspired (iSM default) — same family as ism3.ai, iSM industries, CTAL/MMML/SD/GBML/AOE. Current stonecity.com identity retired at cutover. |
| products.stonecity.com | 301 redirect to apex /products/ on cutover; subdomain decommissioned |
Data flow — SPS as source of truth, S3 as read-mirror
The existing nightly img-integration Lambda gets a third sink. One SPS pull, three downstream writes:
| Sink | Status | Purpose |
|---|---|---|
| HubSpot Products | Live | B2B attribution + reporting on which products tie to which customers |
| Square Catalog | Live | POS pricing reference at the register |
| s3://stonecity-products/ | Phase 2 build | Website read-mirror — Next.js generateStaticParams + Bedrock KB indexer both source from here |
The pricing rule (public surface)
Pricing exists in our cache but is never serialized into any public response.
Surfaces that show price: ❌ product pages, ❌ catalog index, ❌ category/subcategory pages, ❌ concierge replies, ❌ structured data (no offers.price in JSON-LD), ❌ sitemaps.
Surfaces that may show price: ✅ internal admin (auth-gated), ✅ HubSpot deal records (post quote-request, internal CRM only).
Sentinel's system prompt forbids quoting prices. Customer pricing questions get this response: "Pricing depends on slab availability, finish, and project scope. The fastest way to get a real number is to request a quote — I can help you start one for the products you're interested in."
Sentinel concierge — Stone City joins apex-portal as tenant #9
Major scope reduction (audit 2026-05-11): apex-portal already runs a multi-tenant concierge SaaS in production. 8 client sites are live tenants today — 4 service businesses (Signature Dentistry, Miami Medical Malpractice, Chicago Truck Accident Lawyers, All-One Exteriors) + 4 editorial atlases in the 4site rollout (Marque, Subdial, EldercareAtlas, RoofingTechPro). Stone City joins as the next tenant. We're not building a Bedrock concierge from scratch.
| Piece | Detail |
|---|---|
| Backend | apex-portal /api/concierge SSE stream — Bedrock + ConverseStreamCommand on us.anthropic.claude-sonnet-4-6 (migrated 2026-05-05 from Anthropic Managed Agents) |
| Shared widget | @isimplifyme/ui@^1.1.7+ — floating trigger, side-panel chat (480px desktop, full-screen mobile), mobile footer-collision detection |
| Tenant config | s3://isimplifyme-concierge/tenants/stonecity/persona.json — brand voice, FAQs, tools_enabled allowlist, lead_form schema, pricing-rule system prompt |
| NEW for Stone City: product RAG tool | search_stonecity_products tool registered in apex's MCP registry. Input: { query, max_results, category_filter }. Backend: Bedrock Knowledge Base over Stone City products (s3://stonecity-products/by-sku/*.json). Returns { sku, name, category, thumbnail_url, web_url, summary } — explicitly excludes pricing. |
| Enriched copy | Nightly Lambda generates 2-3 paragraph customer-facing descriptions from sparse SPS metadata via Claude. Indexed into the KB; sparse SPS fields → rich retrievable content. |
| Stone City site work | Matches 4site rollout pattern exactly: npm i @isimplifyme/ui, mount widget in layout.tsx, add 3 proxy routes (/api/concierge, /api/concierge-lead, /api/concierge-emergency-tap), wire APEX_LEAD_SECRET from GitHub. |
| Cost | ~$5-20/mo Bedrock KB storage + ~$0.01-0.04 per concierge query (existing rate). ~$55 one-time enriched-copy backfill + ~$5-10/mo ongoing. |
WP audit findings (2026-05-11)
Inventory of what migrates from www.stonecity.com:
Plugins to replace: Yoast SEO (sitemap + JSON-LD → Next.js native), HubSpot WP plugin v11.3.39 (form embeds → React component using hbspt.forms.create(); keep page tracking js.hs-scripts.com/50779605.js), Modula (gallery → custom React). Quform plugin is installed but inactive — safe to deactivate on cutover. Custom theme stone-city retired in favor of Clay-inspired Next.js. Full audit: ~/claude/projects/stonecity/wp-audit-2026-05-11.md.
HubSpot forms + meetings inventory
Live audit of portal 50779605 + WP page content (via REST). 5 active forms + 3 Meetings widgets. Forms stay in HubSpot — Next.js just re-embeds them.
| Page | Type | HubSpot ID |
|---|---|---|
| /contact/ | Form | b9c937bc-… "General Contact Form updated" |
| /request-a-consultation/ | Form | c60ebed1-… "Consultation Request form" older version live; newer "updated" exists but unused |
| /tile-sale/ | Form | b5b290be-… "Tile Sale Lead Form" |
| /latest-sale/ | Form (reused) | b5b290be-… (same as /tile-sale/) |
| (printed QR code) | Form | 37a388bd-… "Register Your Stone Warranty" |
| /trade-pro/ | Meetings | meetings.hubspot.com/sara647/45mins |
| /book/ | Meetings (×2) | meetings.hubspot.com/sara647 + sara647/in-person- |
Portal has 9 forms total, of which 5 are referenced from the live site. The other 4 are cleanup candidates: 0c38a461 Survey form, 7f8c2ff8 Sign up form, 864a5414 "New blank form (January 27, 2026)", plus two stale "updated" versions (c3a74d70 Consultation Request form updated, e58278ff Tile Sale Lead Form updated) that never replaced their predecessors. Resolve before rebuild — task tracked.
Phased plan
| Phase | Scope | Effort |
|---|---|---|
| 0 Discovery | HubSpot form cleanup (4 stale forms) · brand assets decision · WP admin done (27 pages, 81 projects, 53 posts, 9 HubSpot forms, 2 Modula galleries) · Sentinel resolved (tenant of apex-portal, not greenfield) | 1-2 days |
| 1 Foundation | New stonecity-next repo, SST scaffold, marketing pages, staging environment | 1-2 weeks |
| 2 Product catalog | S3 cache sink in nightly Lambda · 11k product pages via ISR · sitemap · image strategy | 2-3 weeks |
| 3 Forms + meetings re-embed | 5 HubSpot forms + 3 Meetings widgets re-embedded into Next.js. Forms stay in HubSpot. Preserve js.hs-scripts.com/50779605.js tracking. | 3-4 days |
| 4 Sentinel concierge (tenant on apex-portal) | Site: @isimplifyme/[email protected]+ + 3 proxy routes + widget mount. Apex side: DDB tenant row, persona JSON, NEW search_stonecity_products MCP tool, Bedrock KB + indexing. | 4-5 days |
| 5 Content migration | Blog (53) + Projects (81) via WP REST → MDX · media library transfer · 301 mapping | 2 weeks |
| 6 Production cutover | DNS · WP decommission · 301 monitoring · retire products.stonecity.com subdomain | 1 week |
| Total: 7-10 weeks dedicated focus, no SPS-side blockers. (Revised down: -3 days Phase 3 re-embed pattern, -7 days Phase 4 apex-portal tenant pattern.) | ||
Open questions
Sentinel implementation sourceResolved — apex-portal multi-tenant concierge; Stone City joins as tenant #9. Addsearch_stonecity_productstool to apex MCP registry.- Modula galleries — still need WP admin access to count active galleries and plan image extraction (other audit items resolved by REST API)
- HubSpot form cleanup decision — for the two stale "updated" form versions in the portal (c3a74d70, e58278ff), either deploy them and retire the older live forms, or delete the orphans. See task #11.
- Brand assets — Clay aesthetic (iSM default) or carry forward current stonecity.com identity?
- products.stonecity.com URL fate — fold into
/productson apex, or keep subdomain as 301?
Full spec: ~/claude/projects/stonecity/architecture-plan-2026-05-11.md. WP audit: ~/claude/projects/stonecity/wp-audit-2026-05-11.md. Update those docs as decisions are made.
10 Quote builder (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.
11 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
12 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 |