kalup

Targets and credentials

Named portals, the portal check, protected targets, where keys come from, and per-target overrides

A target is a named portal. Every command that talks to HubSpot runs against one target, and every target is pinned to one portal ID, so a key for the wrong portal is caught before anything is read.

Declaring targets

Targets live in kalup.config.ts:

import { defineConfig } from '@kalup/core'

export default defineConfig({
  objects: {
    companies: { include: ['name', 'domain'] },
    subscription: {},
  },
  targets: {
    sandbox: {
      portalId: 1111111,
      credentials: { read: { env: 'HUBSPOT_SANDBOX_KEY' } },
    },
    production: {
      portalId: 2222222,
      protected: true,
      credentials: { read: { env: 'HUBSPOT_PROD_READ_KEY' } },
    },
  },
})

Name targets after what they are to you: sandbox, production, acme-eu, or a client's name. A name carries no meaning to Kalup: protection and drift come from the fields you set, not from the name. kalup init suggests sandbox for a sandbox or developer test account and production for a standard account, and --target <name> picks your own. The one reserved name is config, which validate rejects with E_TARGET_NAME.

Choosing a target

pull, plan and snapshot run against one target, picked by one rule:

SituationTarget
--target <name>That target. An undeclared name is E_UNKNOWN_TARGET, exit 3, and never falls back to anything
No flag, defaultTarget setThe default
No flag or default, one targetThe only target
Several targets, none selected, a person at a terminalKalup lists each name with its portal ID on stderr and asks. Ctrl-C, the end of input or three wrong answers is E_CANCELLED, exit 1
Several targets, none selected, --json, no terminal, or CI setE_TARGET_REQUIRED, exit 1, listing the names and portal IDs
No targetsE_NO_TARGETS, exit 3

All of this happens after the config validates and before any request. Kalup never picks the first of several targets, and it does not remember a previous choice: each command selects again.

In a project with one portal, no flag is needed:

npx kalup pull

With several, pass the name, or set defaultTarget once in kalup.config.ts:

export default defineConfig({
  defaultTarget: 'sandbox',
  targets: {
    sandbox: { portalId: 1111111 },
    production: { portalId: 2222222, protected: true },
  },
})

defaultTarget must name a declared target, or validate reports E_DEFAULT_TARGET. status lists every target and marks the default. The text output of pull names the target and its portal, and plan and snapshot do so when the flag did not: Target sandbox, portal 1111111 (defaultTarget). Fix hints always name the target explicitly.

Agents: with several targets and no default, ask the user which portal to use and pass --target <name>. Selecting a target never approves a write.

The portal check

portalId is the portal's Hub ID, the number in the HubSpot account menu. It must be a positive integer, and validate reports a missing or malformed one as E_PORTAL_ID.

Before a command reads anything else, it asks HubSpot which portal the key belongs to. When the answer is not the pinned portalId, the command stops with E_TARGET_PORTAL_MISMATCH and exit 4, and writes nothing:

E_TARGET_PORTAL_MISMATCH: The key in HUBSPOT_SANDBOX_KEY belongs to portal 2222222, not portal 1111111 pinned for target sandbox. (fix: The key in HUBSPOT_SANDBOX_KEY belongs to portal 2222222. Ask the user to check the key and the pinned portalId for target sandbox. For a recreated test portal or sandbox, the user can run kalup target rebind sandbox --portal <id> in a terminal; it refuses STANDARD accounts.)

Exit 4 means a person has to act. Either the key is the wrong one, or the pin is. Kalup never changes the pin by itself, and an agent should not either. When a test portal or sandbox was recreated under a new Hub ID, a person moves the target with kalup target rebind at a terminal. See Working with AI agents.

Protected targets

protected: true marks a target that needs extra care, usually production. kalup apply applies to it only when a person at a terminal confirms, or a reviewed CI job passes --approve, and --yes never covers it. kalup status shows it for every target.

When the config does not say, only a developer test account, a sandbox and an app developer account are not protected. Every other account type is protected, a standard (production) account and a type Kalup does not know included. plan, apply and status all follow this rule, and status marks the value as the default. kalup init never writes it: set it to keep a target protected whatever its account type.

A protected target is applied by a person at a real terminal, who types the target name, or by a reviewed CI job with --approve and a write key only that job holds. See Approval.

Where the key comes from

Each target names the environment variable that holds its read key:

credentials: { read: { env: 'HUBSPOT_SANDBOX_KEY' } }

A target without credentials reads HUBSPOT_SERVICE_KEY.

Kalup looks for the variable in two places, in this order:

  1. The process environment.
  2. A .env file in the project directory, the directory that holds kalup.config.ts.

.env takes KEY=value lines. Blank lines, # comments, an export prefix and single or double quotes are all fine. Kalup reads only the variable a target names. It does not load the rest of the file into the environment.

# .env, never committed
HUBSPOT_SANDBOX_KEY=...
HUBSPOT_PROD_READ_KEY=...

When the variable is set in neither place, the command stops with E_MISSING_KEY. The message names the variable and never contains a value. Kalup never prints, logs or writes a key, not even in an error.

Keep .env out of git. Keys never enter the IR, the object files or any output.

Which key to use

apply writes with credentials.write when the target names one, otherwise with the read key, and sends every request of the run with it, reads included. That key needs the read scopes too, and the write scopes of what the plan changes. Read commands never use it.

Use a HubSpot service key with the read scopes for the objects in scope. kalup init prints the exact scopes the key needs, one per object, and kalup status checks each one. init and status also recommend one crm.objects.<object>.read scope, such as crm.objects.companies.read: HubSpot's Limits Tracking answered 403 to a key with crm.schemas.* scopes only on a developer test account (2026-09-29), so plan could not check the property limit and warned with W_LIMIT_UNREADABLE. The scope also lets the key read that object's records, which Kalup never requests. Whether one such scope is enough is not yet confirmed against a live portal, and status does not check it. A key can only hold scopes its creator has, so a Super Admin should create it.

HubSpot is switching off the creation of new legacy private apps in autumn 2026, and service keys replace them, so Kalup is built around service keys.

credentials.write names the key that apply, state rebuild --write and target rebind use; read commands never use it. apply --approve needs a target to name its own write key, read from the process environment and never from .env. Sandbox, production and CI shows the setup.

Per-target overrides

A resource can have a different name, label or option list in one portal than in your files. overrides maps an address to what differs in that target:

production: {
  portalId: 2222222,
  overrides: {
    'property:companies/billing_status': { name: 'billing_state' },
    'property:companies/renewal_terms': { definition: { label: 'Contract terms', description: '' } },
  },
},

In this version:

  • name works in every command that reads a portal. Kalup reads billing_state from the production portal and treats it as property:companies/billing_status. When the portal holds both names, the command stops with E_OVERRIDE_AMBIGUOUS, because the override would hide a real property. A swap, where each of the two addresses names the other, is fine. When the portal has no billing_state, the address is absent there, and plan blocks it instead of creating it.
  • skip: true works in every command that reads a portal. The resource is left out on that target: not read, not compared, not planned. A skipped group takes its config properties with it.
  • definition replaces whole fields on that target. Each field it states replaces the shared one, and the rest stays shared. options is a whole list in the override's order, and an explicit empty value such as description: '' is owned there. plan, apply, compare and docs use the result for that target, so one shared definition serves every client portal and each states only what differs. pull from that target writes the portal side of an overridden field into the override, never into the shared file, and never touches another target's overrides. kalup.config.ts lists the fields a target may override.
  • lookup is validated but not applied. It is for lookup resources such as teams and owners, which this version does not manage: plan blocks such a resource, and compare reports it as unknown on that target.
  • Every override key must be an address in your files. An unknown one is E_UNKNOWN_OVERRIDE. npx kalup ir lists the valid addresses.

drift

drift: 'hold' | 'overwrite' decides what a plan does with edits made in the HubSpot UI, and is recorded in the plan's policy and its approval digest. hold is the default: a unit that only HubSpot changed (drift) or that both sides changed (a conflict) is held, with the command that takes the portal side and the one that takes config's. overwrite writes config over both, labelled reverts-ui-edit; it suits a personal sandbox where the files should always win. Telling the two apart needs the base in the portal's state. A unit with no base is diverged and held under either setting, unless the target sets adopt: 'overwrite': then config wins on it, as a risky step labelled overwrites-portal that --yes never covers, a stated policy for rolling a blueprint onto a client portal. See kalup plan.

allowDestroy, yesLimit and mode are target settings too: whether this portal may lose things, how many writes one --yes covers, and whether takeover applies here. allowDestroy and yesLimit exist only per target, so a project-level setting never reaches a portal that did not opt in.

Check a target

npx kalup status --target sandbox

status reports, per target: whether the key is set, whether the portal matches the pin, the account type, the time zone, whether it is protected, and whether each read scope is present. It exits 0 when everything passes, 1 when a target has a problem, and 4 on a portal mismatch. See kalup status.

On this page