Skip to content

One-Time CRM Setup

Use this pattern for app templates that require CRM configuration before the app can work properly, such as custom properties, property groups and pipelines.

Use one-time CRM setup when a template:

  • needs custom CRM properties or a pipeline to exist before the UI or serverless logic can run successfully
  • needs those changes to be repeatable across portals
  • benefits from an install-time summary of required scopes and CRM changes

Do not use this pattern for templates that only need standard CRM scopes, saved config, or external API credentials.

Add a setup object to the template’s tc-config.json:

{
"docs_url": "https://tradingcards.dev/docs/templates/example-template/",
"setup": {
"enabled": true,
"requiredOptionalScopes": [],
"crmChangesSummary": [
{
"objectType": "example_object",
"title": "Create property group",
"description": "Creates the template-specific property group."
}
],
"steps": [
{
"kind": "propertyGroup",
"objectType": "example_object",
"name": "example_template_object",
"label": "Example Template",
"displayOrder": -1
},
{
"kind": "property",
"objectType": "example_object",
"groupName": "example_template_object",
"name": "example_property",
"label": "Example Property",
"description": "Used by the template runtime.",
"type": "string",
"fieldType": "text"
},
{
"kind": "pipeline",
"objectType": "deals",
"name": "example_pipeline",
"label": "Example Pipeline",
"stages": [
{ "label": "Open", "metadata": { "probability": "0.2" } },
{ "label": "Won", "metadata": { "probability": "1.0" } },
{ "label": "Lost", "metadata": { "probability": "0.0" } }
]
}
]
}
}

Steps run in the order listed. Each step’s kind says what it creates:

KindCreatesRecognised as existing by
propertyGroupA property group on objectTypeobjectType and name
propertyA property in groupName on objectTypeobjectType and name
pipelineA pipeline with its stages on objectTypeobjectType and label, ignoring case

The discriminator is kind because a property step already uses type for the HubSpot property type. Stage metadata follows the Pipelines API: probability for deals, ticketState for tickets.

Templates written before steps existed declare propertyGroups and properties as two separate lists. Those lists still work, run before steps, and can be mixed with it.

  • Reuse the template’s top-level docs_url for the setup guide link. Do not add a second docs URL inside setup.
  • Put the schema scopes setup needs in the app’s required scopes and leave requiredOptionalScopes empty, as Custom Deal CPQ does. The runner cannot yet detect which optional scopes are granted, so any scope listed there blocks setup.
  • Keep steps declarative so the setup runner can execute without template-specific branching.
  • List a property group before the properties that use it.
  • Setup never changes a resource that already exists. Adding an option to a property or a stage to a pipeline in tc-config.json only affects portals that have not run setup yet.
  • Put any custom properties created by setup into a property group named for the template.
  • Use the template name for the property group display label.
  • Use a stable slug derived from project_id plus object type for the property group internal name.

Templates that set setup.enabled to true should expose a setup entry point on the Settings page.

The setup modal should show:

  • a confirmation message
  • the required optional scopes and whether they are currently enabled
  • a summary of the CRM changes that will be made
  • a Run setup button
  • a View setup guide button

Run setup should be blocked when the required optional scopes are missing. When the template lists none, skip the scope check and its section of the modal.

Implement two functions, *_get_setup_status and *_run_setup, on top of the shared runner in @tradingcards-dev/setup. Add it to the functions package.json and, if the template bundles its functions, mark it external.

const { getSetupStatus } = require("@tradingcards-dev/setup");
const templateConfig = require("../../../tc-config.json");
exports.main = async () => {
try {
return { success: true, ...(await getSetupStatus(templateConfig)) };
} catch (error) {
return { success: false, error: error.message || "Unable to load setup status" };
}
};
const { formatSetupFailures, runSetup } = require("@tradingcards-dev/setup");
const templateConfig = require("../../../tc-config.json");
exports.main = async () => {
try {
const result = await runSetup(templateConfig);
if (result.failed.length > 0) {
return { success: false, error: formatSetupFailures(result.failed), result };
}
return { success: true, result };
} catch (error) {
return { success: false, error: error.message || "Unable to run setup" };
}
};

getSetupStatus:

  • reads the template setup metadata and reuses the top-level docs_url
  • reports each step and whether its resource already exists, in steps and resourceStatus
  • reports requiredOptionalScopes in scopeStatus

runSetup:

  • stops before making changes if requiredOptionalScopes lists anything
  • creates each missing resource in step order
  • treats already-existing resources as a successful skip
  • records a failed step with HubSpot’s error and carries on with the rest
  • returns one entry per step with its outcome, including the ids HubSpot assigned to a new pipeline and its stages

A function that bundles tc-config.json must be re-bundled whenever the setup block changes, or the deployed function keeps running the old list.

The setup runner must be safe to re-run. Existing resources should not cause failure, and reruns should clearly report what was created versus skipped.