# 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. 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.

[figure: Where an app object lives]

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

```ts
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:

```ts
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.

**Where next**

- [App events: put your app on the timeline](/docs/guides/app-events)
- [Workflow actions: act on your object from workflows](/docs/guides/workflow-actions)
- [Marketplace: the review your scopes will meet](/docs/guides/marketplace-listing)

