For the contacts-into-deals project, 2026-10-06. Numbers are from your portal (2578781). Written from the portal audit (scripts/hubspot-audit.mjs) and the stored workflow definitions. Nothing here changes HubSpot.
There's no SQL you or Ishwarya can reach. HubSpot hosts the databases and you work through the screens, reports, workflows and the API. The SQL below is a way of picturing what each screen action does, not real queries.
A café owner sees a Meta ad and fills in the Revenue form. Here is everything that happens, with the "table" each step touches.
sequenceDiagram autonumber actor Owner as Café owner participant Form as Meta ad form participant C as contacts table participant E as Workflow engine participant D as deals and companies tables participant A as associations (links) actor Reinie Owner->>Form: fills it in Form->>C: new contact row (name, phone, Original Source = Paid Social) C-->>E: event: form submitted E->>D: Routing: new company, new deal (Revenue, New Inbound, owner Reinie) E->>A: link contact, deal and company E->>C: Routing: contact owner = Reinie D-->>E: event: deal created E->>E: Set Channel waits 2 minutes E->>D: Deal Channel and Sub-Channel, read from the contact's source D-->>C: HubSpot's lifecycle sync: contact becomes Opportunity Reinie->>D: drags the card to Demo Scheduled (Deal Stage changes) D-->>E: event: deal stage changed E->>D: Lead Stage = SQL (new workflow 3)
If HubSpot were one SQL database, your portal would look roughly like this. Each object is a table. Your portal has 476 contact properties (87 made by EPOS), 436 deal properties (161 by EPOS) and 285 company properties (41 by EPOS). Most of them are empty on most rows.
erDiagram
CONTACTS ||--o{ ASSOCIATIONS : "linked by"
DEALS ||--o{ ASSOCIATIONS : "linked by"
COMPANIES ||--o{ ASSOCIATIONS : "linked by"
DEALS }o--|| STAGES : "Deal Stage"
STAGES }o--|| PIPELINES : "belongs to"
DEALS }o--|| OWNERS : "Deal owner"
CONTACTS }o--|| OWNERS : "Contact owner"
CONTACTS {
id record_id
text name_phone_email
text original_source "set by HubSpot tracking"
text lifecycle_stage "Lead, MQL, SQL, Opportunity, Customer"
text enquiry_status "EPOS field"
text lead_stage "EPOS field"
text more "476 properties"
}
DEALS {
id record_id
text deal_name
id pipeline "Revenue Team SG or MA Team SG"
id deal_stage "the Kanban column"
id owner
text deal_channel_sub_channel
text industry_parent_industry
text lead_stage
text more "436 properties"
}
COMPANIES {
id record_id
text name
text more "285 properties"
}
ASSOCIATIONS {
id from_record
id to_record
text type "contact-deal, deal-company, contact-company"
}
PIPELINES {
id pipeline_id
text label
}
STAGES {
id stage_id
text label "New Inbound, Demo Scheduled"
number probability "0.1 to 1.0"
}
OWNERS {
id owner_id
text name "235 users"
}
| You do this in HubSpot | It amounts to this |
|---|---|
| Open the Revenue board | SELECT deal_name, owner, amount FROM deals
WHERE pipeline = 'Revenue Team SG'
GROUP BY deal_stage -- one column per stage |
| Drag a card to Demo Scheduled | UPDATE deals SET deal_stage = 'Demo Scheduled' WHERE id = 123;
-- and HubSpot logs the old value, new value, time and who |
| Open a deal and see its contact | SELECT c.* FROM associations a JOIN contacts c ON c.id = a.to_record
WHERE a.from_record = 123 AND a.type = 'deal-contact' |
| A report: Revenue deals by channel this month | SELECT deal_channel, COUNT(*) FROM deals
WHERE pipeline = 'Revenue Team SG' AND create_date >= '2026-10-01'
GROUP BY deal_channel |
A stage is just a value. The pipeline and its stages are a short lookup list (Revenue has 14 stages, each with a probability: New Inbound 10%, Demo Scheduled 40%, Closed Won 100%). A deal "is in" a stage because its Deal Stage property holds that stage's ID. That's why workflow 3 can say "probability 40% or more" instead of listing stages.
The same fact is often kept twice. Source, industry, owner and lead stage exist on the contact and on the deal, and 90% of deals have exactly one contact. So for most leads, one lead is stored as two rows that workflows keep copying between. That copying is what the project removes: the team reads and writes the deal, and the contact becomes the name-phone-email row that forms, calls and ads need.
A workflow is a saved rule with three parts. HubSpot stores each one as a document (we read them as JSON): a trigger, a list of numbered steps, and the arrows between the steps.
| Part | What it is | Example from your portal |
|---|---|---|
| Object | The one table it runs on. A contact workflow enrols contacts and a deal workflow enrols deals. It can still read and write a linked record through the association. | New 4 runs on deals but reads the linked contact's source fields. |
| Trigger | Either an event (record created, a property changed, a form submitted) or a filter (enrol a record when it starts matching some conditions). | Routing: "one of 18 forms is submitted". New 6: "Revenue deal, Industry known, Parent Industry unknown". |
| Steps | Set a property (an UPDATE), create a record (an INSERT plus links), branch (IF), wait, or notify (email, task, in-app). | New 5: create the MA deal, then set the Revenue deal's stage to Drop. |
Your portal has 123 workflows, before our six: 44 on contacts (23 on), 71 on deals (48 on), 6 on tickets and 2 on companies.
The bottom layer is from HubSpot's annual report to the US SEC, which says it runs on HBase, Kafka, Vitess (to run MySQL at scale) and Elasticsearch. Which store holds what isn't public, so don't present the bottom layer in detail. It's there to show that the tables above are a mental model, not something EPOS manages.
This is the part that's ours. Everything lives in C:/dev/epos (git, on your Forgejo).
| Step | How | Script |
|---|---|---|
| Read the portal | A HubSpot private app ("service key") reads records, pipelines, owners and properties through HubSpot's public API. | hubspot-audit.mjs |
| Read every workflow | The public API showed only 4 workflows to the service key. So the definitions came through your logged-in browser, using the same internal API the Workflows page uses. | captured into .cache/live-flows.json |
| Write the six | A script builds each new workflow's JSON, copying steps from the old ones where possible, and saves it to HubSpot switched off. | hubspot-migration-workflows.mjs |
| Check they match | Compares each new workflow's JSON with the old ones it replaces: same triggers, same conditions, same copied steps. 39 of 39 checks pass. | hubspot-migration-parity.mjs |
| Check nothing breaks | Lists every field the retired workflows write and every remaining workflow that reads one. | hubspot-migration-deps.mjs |
| Test live | Copies of workflows 3 and 4, fenced to named test deals, ran on test records. The copies and records were then deleted. | hubspot-migration-e2e.mjs |
| Draw them | Flowcharts of each new workflow against the old ones. | hubspot-migration-diagrams.mjs |
Switching over is all in HubSpot's own screens: turn the new workflow on, turn the old ones off. No code runs in production and nothing new has to be hosted.