Guides · Build

App objects

Most integrations end up needing a record type HubSpot doesn't have: the subscription, the shipment, the policy. The classic answer was asking every customer's admin to create a custom object by hand and praying the property names match. An app object inverts that: your app defines the object type, ships it, and every install gets the same schema.

Outcome
A CRM object type declared in TypeScript, created in every portal that installs your app, with typed reads and writes from your handlers.
Prerequisites
  • An HS-X project with marketplace distribution and OAuth auth
  • The CRM object scopes for your object in the scopes array

TL;DR — Declare the type with appObject (name, labels, typed properties, display configuration), attach it via the objects array in defineApp, and deploy. Every installed portal gets the object; your handlers work with records through ctx.appObjects with get, create, update, and archive. One schema, everywhere, owned by the app.

The app owns the schema

An app object and a portal custom object answer different questions. A portal custom object belongs to one account: an admin creates it, names its properties, and your integration has to discover and adapt to whatever they built. An app object belongs to the app: you define it once, it ships inside your project, and HubSpot creates it identically in every portal that installs you.

That difference is why HS-X supports app objects only. An app built on per-portal custom objects has a different shape in every install, which makes typed handlers, schema migrations, and support all guesswork. With app objects the schema in your repository is the schema in production, in every portal, and your handler code can be typed against it.

The trade is uniformity: every install gets the same object. There is no per-install variant, no hiding properties by plan tier, and removing a property later is a destructive change you plan deliberately at deploy time rather than something codegen does silently.

Define the object, attach it to the app

import { appObject, appObjectAssociation, defineApp } from '@hs-x/sdk';
 
export const subscription = appObject('external-subscription', {
  name: 'SUBSCRIPTION',
  label: 'Subscription',
  singularForm: 'Subscription',
  pluralForm: 'Subscriptions',
  primaryDisplayLabelPropertyName: 'external_id',
  searchableProperties: ['external_id'],
  properties: {
    external_id: { type: 'string', label: 'External ID' },
    plan: { type: 'enumeration', label: 'Plan', options: ['starter', 'pro', 'enterprise'] },
  },
});
 
export const subscriptionContact = appObjectAssociation('subscription-contact', {
  fromObjectType: 'SUBSCRIPTION',
  toObjectType: 'CONTACT',
  label: 'Contact',
  inverseLabel: 'Subscriptions',
});
 
export default defineApp({
  name: 'Acme Billing',
  distribution: 'marketplace',
  auth: 'oauth',
  platformVersion: '2026.03',
  scopes: ['crm.objects.contacts.read'],
  objects: [subscription],
  objectAssociations: [subscriptionContact],
});

The name is the HubSpot object type, uppercase by convention. primaryDisplayLabelPropertyName decides what a record is called in the CRM UI, and the property map is the typed schema your handlers inherit. Associations are declarative: fromObjectType, toObjectType, labels for each side, and an optional cardinality. They ship as metadata; HubSpot wires the relationship at deploy.

From these declarations, codegen emits the app-objects and app-object-associations metadata files inside your generated HubSpot project, plus typed references your worker code imports. hs-x check validates the declarations before anything deploys.

Typed records in handlers

Handlers get ctx.appObjects with four operations, each accepting the typed reference or a raw object name:

import { defineWorker, ok } from '@hs-x/sdk';
import { subscription } from '../hsx.config.js';
 
const worker = defineWorker('billing');
 
worker.tool('upgrade-plan', {
  label: 'Upgrade plan',
  input: { subscriptionId: { type: 'string', label: 'Subscription ID' } },
  output: { plan: { type: 'string' } },
  async handler({ input, appObjects }) {
    const record = await appObjects.get(subscription, String(input.subscriptionId));
    const updated = await appObjects.update(subscription, record.id, { plan: 'pro' });
    return ok({ plan: String(updated.properties.plan) });
  },
});
 
export default worker;

get, create, update, and archive map onto HubSpot's current object APIs for your type, authenticated with the install's token like every other call a handler makes. Using the typed reference (subscription) instead of the string name buys you property-level inference: updated.properties.plan is typed from the declaration.

What this feature is not

Honest edges, so you design around them up front:

  • No per-portal fallback. If a customer wants to bolt extra fields onto your object in their portal, that is HubSpot's standard object customization on their side, not something your app schema controls.
  • Schema changes are deploy decisions. Adding a property is additive and safe. Removing one is destructive; plan it as part of a deliberate release, because nothing auto-migrates records.
  • Association traversal is HubSpot's standard API. The declaration creates the relationship; querying "all contacts on this subscription" goes through the regular associations endpoints via ctx.hubspot.
  • Scopes are yours to declare. The object's CRM scopes belong in your scopes array; marketplace review evaluates them like any other scope request.