What is underneath HubSpot's cards and boards

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.

The short version

1. A card is a row in a tableEvery contact, deal and company is a record: one row, with one value per property (a column). The Kanban board is a view of the deals table, grouped by the value of one property, Deal Stage. Dragging a card changes that one value.
2. Records link through a separate link tableA deal doesn't contain its contact. HubSpot stores associations, a list of pairs saying "deal 123 belongs with contact 456". Copying fields between them, or reading one from the other, always goes through that link.
3. Workflows are rules that react to changesWhen a row is created or a value changes, HubSpot's workflow engine checks every workflow's trigger. A match runs that workflow's steps for that one record: set a value, create a record, wait, branch or notify. There is no other "backend" to the sales process. It's records, links and rules.

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.

One lead, start to finish

A café owner sees a Meta ad and fills in the Revenue form. Here is everything that happens, with the "table" each step touches.

  1. The form creates a contact. HubSpot's form system adds a row to the contacts table: name, phone, email and the form's answers. HubSpot's own tracking fills Original Source = Paid Social and its drill-downs (Facebook, the campaign). That source tracking only exists on contacts, which is why contacts can't be switched off.
  2. The form submission is an event. The workflow engine sees "form submitted" and finds the workflows that trigger on it: [SG] Revenue IB Leads Routing (triggers on any of 18 forms).
  3. Routing creates the company and the deal. It's a contact workflow, so it reads the contact's fields to decide the path. Then it adds a row to companies, a row to deals (pipeline Revenue Team SG, stage New Inbound, owner Reinie Lim), and the association rows linking all three. It also sets the contact's owner.
  4. The new deal is another event. "Deal created" triggers Set Channel & Sub-Channel. It waits 2 minutes, then reads the contact's source fields and writes Deal Channel and Sub-Channel on the deal.
  5. HubSpot's own sync moves the contact's lifecycle. Because the contact now has an open deal, HubSpot sets its Lifecycle Stage to Opportunity. This isn't a workflow, it's built in, and workflows can't move it backwards (we saw this in the live test).
  6. Reinie works the card. She calls, books a demo and drags the card to Demo Scheduled. That's one change: the deal's Deal Stage value. It's also an event, so stage-change workflows run. After the switch, new workflow 3 sees the stage's probability is 40% and sets the deal's Lead Stage to SQL.
  7. Every change is kept. HubSpot stores the history of each value: old value, new value, when, and what changed it (a person, a workflow, an import). That history is how I measured, for example, that the old BlueTap workflows overwrite the contact's source within 20 seconds.
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)

The data, pictured as tables

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"
  }

What the screens do, as SQL

You do this in HubSpotIt 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

The two Revenue ideas this explains

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.

The workflow engine

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.

PartWhat it isExample from your portal
ObjectThe 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.
TriggerEither 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".
StepsSet 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.

How it runs

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 whole picture, in layers

Where data comes in
Forms and ad lead syncAircall callsWhatsApp and inboxTyped in by handImports
Each one creates or updates a contact
What people see
Kanban boardsRecord pagesReports and lists
Views of the data: reading, and editing one value at a time
↓ creates and edits records ↓
The data
contactsdealscompaniesassociations (links)value history
Rows and values, pictured as tables above
Our scripts (C:/dev/epos)
read records through the APIread and save workflow definitions
Only used to build and check. Nothing of ours runs day to day
↓ every change is an event↑ steps read and write values
Rules that react to changes
Workflows: 123 today, plus our 6 (off)HubSpot built-ins: source tracking, lifecycle sync
This is where the contacts-into-deals project makes its changes
all of it runs on
HubSpot's own infrastructure, hosted. Nobody at EPOS touches it
MySQL, run with VitessHBaseKafka event streamsElasticsearch search

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.

How the new workflows were built and checked

This is the part that's ours. Everything lives in C:/dev/epos (git, on your Forgejo).

StepHowScript
Read the portalA HubSpot private app ("service key") reads records, pipelines, owners and properties through HubSpot's public API.hubspot-audit.mjs
Read every workflowThe 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 sixA 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 matchCompares 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 breaksLists every field the retired workflows write and every remaining workflow that reads one.hubspot-migration-deps.mjs
Test liveCopies 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 themFlowcharts 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.