How I helped Vantage TV*, a performance-television advertising company, replace a tangle of agency and brand relationships with one connected, self‑running source of truth in HubSpot.
* Client name and identifying details have been changed to protect confidentiality. Figures are representative of the engagement.
One agency hires you to run campaigns for several different brands. The agency pays; each brand runs its own campaign. But a HubSpot deal points to only one company, so every deal had to pick a side: the agency and lose the brand, or the brand and lose the agency. Either way, half the picture disappeared.
assets/before-companies.png
assets/before-record.pngEach job gets its own record type. Linked together, they finally answer what one deal never could: an agency's full book of business, and every brand's campaign underneath it.
Each job maps to a native HubSpot primitive: the sale to a Deal, the relationship to parent/child Company associations, the campaign to a Ticket, the money to Python rollups. I deliberately stay away from custom objects, doing as much as possible within the boundaries of the client's existing tier pricing.
Every brand is tagged Direct or Agency-Managed automatically, with its managing agency, campaign status, and revenue sitting on the record as real, sortable fields instead of tribal knowledge.
Added custom company properties brand_relationship, managing_agency and managing_agency_id, then wrote a Python script () that walks each brand's parent-agency association and stamps all three onto the record. Two saved views filter on the field, so "show me every agency-managed brand" is one click, not a spreadsheet.
assets/segments.pngOne number the team works day to day: no campaign noise, no delivery status. Just who's paying, how much, and an honest forecast.
The 8 stages came out of planning meetings and interviews I ran with the sales team, not a template. I built the pipeline (Discovery β Live Campaign) with required-stage progression, tied each close date to its revenue-recognition date so the forecast stays honest, and saved Closing Soon / Booked / Stale cuts on top.
assets/commercial-layer.pngEach brand's campaign runs on its own board (planned, trafficked, live, optimized, wrapped), so delivery status never contaminates the sale.
A campaign has a different lifecycle from a sale, so tracking it in Deals would pollute the sales forecast. Tickets are HubSpot's native object for operational, status-tracked work, so rather than a custom Campaign object I repurposed the standard Tickets object into a 5-stage delivery board, with custom flight-date and media-spend fields, tracked per brand and fully separate from the deal.
assets/delivery-board.pngOff-the-shelf, HubSpot has no field for a "flight date" or a "TV creative." So I gave it their language, as structured, reportable fields instead of free-text notes.
Created 30+ custom properties across Deals, Companies, and Tickets through the CRM API (scripted with a service-account token, since the CLI's user token can't write schema). Each is a typed field, dropdowns and numbers rather than free text, so the data stays clean and everything downstream (cards, views, rollups) reads from it.
assets/deal-record.png// One card that reads the record type and renders
// itself differently on an agency vs a brand.
hubspot.extend(({ actions }) =>
<AccountOverview
fetch={actions.fetchCrmObjectProperties} />
);
const p = await fetch(['brand_segment', β¦]);
if (p.brand_segment === 'agency')
return <BrandPortfolio />; // rollup
if (p.brand_segment === 'brand')
return <BrandSnapshot />; // its own
View an agency, and the card rolls up every brand beneath it. View a stand-alone brand, and it shows just that brand's own revenue and campaign. View an agency-managed brand, and it adds who manages it, with a one-click link back to the agency.
A single React component reads brand_segment and branches: the agency face parses a rolled-up JSON of every linked brand; the brand face reads its own revenue fields, adding the managing agency when there is one. Everything is pulled live via the fetchCrmObjectProperties action.
assets/agency-portfolio.png
assets/brand-card.png
assets/live-card.png// hubspot.fetch STRIPS client headers and wants an
// OBJECT body; it serializes + sets Content-Type.
// A stringified body + manual header β HTTP 400
// *before* the request reaches the API.
const kick = await hubspot.fetch(`${BASE}/qualify`, {
method: 'POST',
body: company, // object, not JSON.stringify
});
// async job β poll every 5s until it's done
for (let i = 0; i < 90; i++) {
await wait(5000);
const j = await get(`/result/${kick.job_id}`);
if (j.status === 'done') { render(j); break; }
}
A campaign is contracted up front, then delivered and invoiced in phases. The gap is backlog: committed but not yet earned.
A $300K contract, $180K delivered β $120K still to deliver. "What we've sold" and "what we still owe" become two different, always‑current numbers, rolled up automatically per brand and per agency.
Invoiced-to-date is a deal property; backlog is derived as booked β invoiced in the rollup script (), so "sold" and "still owed" recompute on every sync, per brand and per agency, with no manual reconciliation.
# scheduled rollup: a lightweight "workflow"
SIGNED = {"Legal / IO", "Live Campaign",
"Closed Won"}
for deal in brand_deals:
if deal.stage in SIGNED:
booked += deal.amount
invoiced += deal.invoiced_to_date
elif deal.stage != "Closed Lost":
pipeline += deal.amount
backlog = booked - invoiced
write_back(agency, booked, backlog, pipeline)
HubSpot's own enrichment is a paid add-on that mostly fills in a company you already have. It doesn't hand you the buying committee. Apollo does both.
# apollo JSON in β mapped, deduped, connected record
org, people = load(apollo_org), load(apollo_people)
# reuse the company if the domain exists, else create
company = find_by_domain(org["domain"]) \
or create_company(map_company(org))
for p in people: # decision-makers β contacts
c = create_contact(map_contact(p))
associate(c, company)
deal = create_deal(company, # Brands pipeline
stage="Discovery")
associate(deal, company)
The rollups surface as sortable columns (brands, booked, backlog, campaigns live), so the whole book ranks itself by revenue.
The rollup fields become sortable company columns, and I saved a full set of views (Agencies, Direct Brands, Agency-Managed, plus deal, ticket, and contact cuts), so every operational question is a saved view, not a rebuild.
assets/book-of-business.pngNaming the four layers before touching a single field.
Booked, invoiced, backlog, all modeled the way the business earns.
Objects, associations, pipelines, and custom UI extensions.
React cards, live-API integration, and automation in Python.
A lean design that avoids paid add-ons, with a clean upgrade path baked in.
From a blank portal to a system a team can run on day one.