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.
TL;DR — Declare the type with appObject (name, labels, typed properties, display configuration), attach it via the objects array in defineApp, and deploy. HubSpot creates the object in every installed portal once the object name is approved; 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 (or an HS-X sync with manageSchema: "full" creates it on deploy), names its properties, and an integration has to discover and adapt to whatever was 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.
Prefer app objects for anything your handlers are typed against. 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. A sync into a custom object is still the right tool when a single portal owns the shape, which is why the syncs guide supports it. HS-X’s own CRM sync app is built the same way: standard objects first, app objects as the enhancement layer once HubSpot approves them.
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.
App objects require HubSpot approval before the project build accepts them. You request access through the app objects and events form in the developer portal, HubSpot confirms the object names you may use, and name in your declaration must match an approved name exactly. Build the declaration and handlers ahead of that confirmation if you like; the HubSpot build will reject the app-objects component until it lands.
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',
description: 'A subscription managed by Acme Billing.',
appPrefix: 'Acme',
primaryDisplayLabelPropertyName: 'external_id',
secondaryDisplayLabelPropertyNames: ['plan'],
searchableProperties: ['external_id'],
properties: {
external_id: { type: 'string', label: 'External ID' },
plan: { type: 'enumeration', label: 'Plan', options: ['starter', 'pro', 'enterprise'] },
},
settings: { hasRecordPage: true, allowsUserCreatedRecords: false, hasEngagements: true },
});
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',
// Replace 12345 with your app id after the first upload.
'crm.app.schemas.a12345_SUBSCRIPTION.read',
'crm.app.objects.a12345_SUBSCRIPTION.view',
'crm.app.objects.a12345_SUBSCRIPTION.create',
'crm.app.objects.a12345_SUBSCRIPTION.edit',
],
objects: [subscription],
objectAssociations: [subscriptionContact],
});name must be the uppercase-snake-case name HubSpot approved. HubSpot exposes the object as a<appId>_<NAME> (the fully qualified name) and prefixes every property with a<appId>_; you never write the prefix in the declaration. appPrefix is what users see in front of the singular and plural names in the CRM UI (“Acme Subscription”), primaryDisplayLabelPropertyName decides what a record is called, and the property map is the typed schema your handlers inherit. description, secondaryDisplayLabelPropertyNames, and the three settings flags are required by HubSpot’s app-object schema, so declare them rather than relying on defaults. 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 project and app config; the object schema itself is validated by HubSpot’s build on hs-x deploy.
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',
objectType: 'SUBSCRIPTION',
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 call HubSpot’s CRM object endpoints for the declared object name, 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. One thing to verify against a test portal before you rely on it: HubSpot documents app-object records under the fully qualified name a<appId>_SUBSCRIPTION, while the runtime addresses them by the bare name you declared. Pass the fully qualified name as the string form if the bare name does not resolve in your portal.
What this feature is not
Limits to design around up front:
- One shape per app. 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. Per-portal shapes are what syncs into custom objects are for.
- Schema changes are deploy decisions. Adding a property is additive and safe. Changing a property’s
typeis not allowed after creation; add a new property instead. 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. App-object scopes are keyed by the fully qualified name: at minimum
crm.app.schemas.a<appId>_SUBSCRIPTION.read, pluscrm.app.objects.a<appId>_SUBSCRIPTION.view,.create,.edit,.merge,.delete, andcrm.app.schemas.a<appId>_SUBSCRIPTION.properties.writefor the operations your handlers perform. You only know<appId>after the first HubSpot upload, so add them on the second deploy. HubSpot suggests shipping them as conditionally required scopes while rolling out; HS-X currently emits onlyrequiredScopesandoptionalScopes, so put them inscopesoroptionalScopes. Marketplace review evaluates them like any other scope request.
