Portal Fixer setup guide
This guide is for a small B2B team with no HubSpot admin. It covers who the app is for, how you connect it, which permissions it asks for, and the Fix Catalog: the only fixes it can apply.
A dry run does not change the portal. Writes are off by default. Reversible description rewrites, custom-group moves, company-name backfills, and quarterly stage-probability updates are recorded as policy-approved for that change set under their playbooks. They do not wait for a separate approval.
Before you start
- Where it is offered. Portal Fixer is for businesses in the United States. It is not offered to businesses in the European Union, the European Economic Area, the United Kingdom, or elsewhere in Europe, the Middle East, or Africa, or to HubSpot accounts that belong to them.
- Who installs it. A Super Admin installs the Portal Fixer HubSpot app on the account. The app uses account-level OAuth. Portal Fixer does not create a user in the account.
- What you need first. A fix plan from Portal Audit. The free audit is the place to start. Apply for the Fixer beta if you want Portal Fixer on a portal. A free beta is open for 3 portals. This page does not set a start date.
- Customer portals. Portal Fixer does not write to a customer portal until the Terms are published. The Terms on this site are still a draft. Writes stay off after install.
Connect the app
A Super Admin installs the Portal Fixer HubSpot app on the account. The app uses account-level OAuth. Portal Fixer does not create a user in the account.
The beta is invite-only. Installing does not keep the connection unless this HubSpot account is already on the beta list. Apply for the Fixer beta with the portal ID. Writes stay off. A dry run does not change the portal. Reversible description rewrites, custom-group moves, company-name backfills, and quarterly stage-probability updates are recorded as policy-approved for that change set under their playbooks. They do not wait for a separate approval.
- Apply, and wait until that portal is accepted.
- A Super Admin uses Install Portal Fixer above. The connection is the app itself, with account-level OAuth.
- Review the permissions below. You can leave an optional permission off. The fix that needs it does not run.
- Import the Portal Audit fix plan.
- Read the dry-run diff. Nothing is written during the dry run.
- Reversible description rewrites, custom-group moves, company-name backfills, and quarterly stage-probability updates are recorded as policy-approved and do not wait for a separate approval. Stage renames, adding or removing a stage, renaming a property group, creating or editing a workflow, and an owner assignment of more than 100 records to any one user wait for a person to approve that exact diff. See How it works.
- Apply writes only the approved fields, then reads them back. You can roll the change back from the snapshot.
Writes stay off until they are turned on for that portal. Apply does not change HubSpot while they are off.
Permissions the app asks for
The table is generated from the scope list in this repository.
| Scope | Where it is used |
|---|---|
oauth | Install, token exchange, refresh, introspection |
crm.objects.contacts.read | backfill:contacts.company search and read-back |
crm.objects.contacts.write | backfill:contacts.company batch update |
crm.objects.companies.read | backfill:contacts.company company names; owner rule company read |
crm.objects.deals.read | pipelines:template-stages; backfill:deals.hubspot_owner_id |
crm.objects.owners.read | Both owner modules |
crm.schemas.contacts.read | descriptions and groups, contacts |
crm.schemas.contacts.write | descriptions and groups, contacts |
crm.schemas.companies.read | descriptions and groups, companies |
crm.schemas.companies.write | descriptions and groups, companies |
crm.schemas.deals.read | descriptions, groups, and pipeline reads |
crm.schemas.deals.write | descriptions, groups, and pipeline stage label PATCH |
automation (optional) | Workflow side-effect reads, assignment-workflow review, and the approval-gated workflows:assignment allowlist (POST/PUT /automation/v4/2027-03-beta/flows) |
crm.objects.deals.write (optional) | backfill:deals.hubspot_owner_id |
crm.objects.tickets.read (optional) | backfill:tickets.hubspot_owner_id |
crm.objects.tickets.write (optional) | backfill:tickets.hubspot_owner_id |
Not requested yet
The uploaded HubSpot app grants the 16 scopes in the table. HubSpot's consent screen shows an unconfigured optional scope as disabled and leaves Connect disabled, so these stay out of optionalScopes until that app is uploaded with them:
| Scope | Where it will be used |
|---|---|
crm.objects.companies.write | backfill:companies.hubspot_owner_id batch update. The module still dry-runs; apply refuses until the scope is granted. |
crm.objects.line_items.read / crm.objects.line_items.write | archive:records: snapshot and archive one line item |
crm.objects.quotes.read / crm.objects.quotes.write | archive:records: snapshot and archive one quote |
crm.schemas.quotes.read / crm.schemas.quotes.write | archive:properties: snapshot and archive one custom quote property |
crm.schemas.line_items.read | Line item property settings. HubSpot has no line item schema write scope. No shipped module uses this scope. |
archive:records and archive:properties stay in the registry. They stay off the public Fix Catalog until every scope they use is requested. Archive is the single-item DELETE on the archive allowlist in docs/safety.md; every other DELETE stays banned. Ticket properties cannot be archived: HubSpot lists no crm.schemas.tickets.* scope.
Not requested, and refused at startup if added: any *.sensitive.* or *.highly_sensitive.* scope, crm.export, crm.import, settings.users.write, settings.billing.write, forms, content, marketing-email.
Deal stage renames use crm.schemas.deals.write, not crm.objects.deals.write.
Optional rows are marked in the table. Without the automation permission, workflow side effects are not checked, and an assignment workflow is not created. Without deal write, deal owner fixes are not written. Without ticket read and write, ticket owner fixes are not written.
How to read a diff
Each row is one field. It shows the current value and the value that would be written. A note names a workflow when the automation permission was granted and an enrollment filter uses that property. If the permission was not granted, the note says workflow side effects were not checked.
Approve, apply, roll back
Reversible description rewrites, custom-group moves, company-name backfills, and quarterly stage-probability updates are recorded as policy-approved for that change set under their playbooks. They do not wait for a separate approval. These still wait for a person's approval of that exact diff: stage renames, adding or removing a stage, renaming a property group, creating or editing a workflow, and an owner assignment that gives more than 100 records to any one user. A run that would go beyond an item's limits does not happen automatically.
You can turn any fix type off for a portal. A type that is off does not run. Turning a type on does not write by itself.
Apply re-reads the portal. If a value changed after the diff was approved, the change set expires and nothing is written. When the diff still matches, Portal Fixer stores an encrypted snapshot, writes the allowlisted fields, and reads them back. You can roll back from the snapshot. Snapshots are kept 90 days by default.
Uninstall
Uninstall the Portal Fixer app in the HubSpot account. That stops Portal Fixer from accessing the account. Email jon@thejonmartin.com to delete the stored snapshots and tokens for that portal. That delete does not call HubSpot.
Fix Catalog
The catalog below is generated from the module list in this repository. It is the list of fixes Portal Fixer can apply. It describes what this app is built to change. It is not a claim that every API path below is confirmed on a current HubSpot page. Where it names a model, that step runs only when a provider is configured. The default asks a person or another program, and an unanswered step is skipped. Nothing is guessed.
The module key is the fix-plan item key. Items with any other key are report-only: the admin UI shows them and the runner will not dry-run them.
Playbooks in docs/playbooks/ decide approval. A dry run with rows and approval().required === false is recorded as policy-approved by policy. Apply is still a separate step and still passes every write gate. Empty diffs are not auto-approved.
| Module | Approval |
|---|---|
descriptions:custom-properties | policy-approved when the rewrite is reversible |
groups:custom-properties | policy-approved for moves; a groupLabel row requires a person |
backfill:contacts.company | policy-approved |
pipelines:stage-probabilities | policy-approved inside the fiscal-quarter window |
pipelines:template-stages | a person, for rename, add, or remove |
backfill:deals.hubspot_owner_id and backfill:tickets.hubspot_owner_id | policy-approved up to 100 records for any one user; above that, a person |
workflows:assignment | a person, before any workflow write |
The sample plan still maps six keys. pipelines:stage-probabilities and workflows:assignment run when a plan item uses those keys.
Stage structure changes use crm.schemas.deals.write. They do not use crm.objects.deals.write.
descriptions:custom-properties
- Rules: DM-001, BP-DM-01. Playbook:
docs/playbooks/descriptions.md. - Objects: contacts, companies, deals. Other objects in the plan are skipped.
- Reads every custom property (
hubspotDefined !== true). HubSpot-defined properties are skipped. - Keeps a description Jev calls valid. Rewrites blank text, a repeated label, placeholder text, or a description that does not match the data pattern.
- Phase 1 patterns are computed in code: fill rate (search totals), property
type/fieldType, and the top enum option labels by count. Record values are not sent to Grok. - Grok writes the replacement. An admin override on the approval form wins.
- Reads:
GET /crm/properties/2026-09/{objectType}andGET .../{propertyName}. - Writes:
PATCH .../{propertyName}with{description}only. - Forms are not read. The
formsscope is banned.
groups:custom-properties
- Rules: DM-003, BP-DM-02. Playbook:
docs/playbooks/property-groups.md. - A custom property in a HubSpot default group is moved. Properties already in a custom group stay there.
- Jev chooses an existing custom group or a new one. Grok names a new group from the property's data use or platform (for example Invoicing data, Shipping information).
- An explicit plan
targetGroupis honored. - Group renames are
groupLabelrows and require a person. The internal name stays.PATCH /crm/properties/2026-09/{object}/groups/{name}sends{label}only. - The module never deletes a group. Rollback moves the property back and leaves a created group in place.
- If the groups payload has no
hubspotDefined, onlycontactinformation,companyinformation,dealinformation, andticketinformationare treated as default groups. The diff note says so.
backfill:contacts.company
- Rule: DQ-001. Playbook:
docs/playbooks/contact-company-backfill.md. - Primary associated company (
associatedcompanyid): write that company'sname, including when the contact'scompanyalready differs. - No association, and the email domain is a business domain: search companies for
domainEQ that domain. Exactly one named company is used. Zero or many are flagged and not written. Jev sees the domain only. - Freemail domains are ignored.
- Only the
companyproperty is written. - Workflow side effects are still checked and logged. Without the
automationscope the note isworkflow side effects not checked.
pipelines:template-stages
- Rules: DM-004, BP-SP-01. Playbook:
docs/playbooks/pipeline-stages.md. - A HubSpot default stage name alone is not a finding.
- A rename comes from an owner-supplied label, or from Jev (safe to change, then SPICED) plus a Grok display name. SPICED is the default methodology.
- Every rename, add, or remove requires a person. The diff includes a draft announcement. The app does not send it.
- Order is not changed. Label patches send
{label}only. Adds send the new stage'sdisplayOrderandmetadata.probability"0.1". - Removal is
PATCH {archived:true}and only when a deal search finds zero deals in that stage.DELETEstays banned. Rollback of an add archives the created stage.
pipelines:stage-probabilities
- Same playbook. Separate module so a rename's approval does not block a probability update.
- Runs in the first 14 UTC days of a fiscal quarter. The quarter is remembered after a verified apply (
portal_settings.last_probability_quarter). - Method: among deals in that pipeline with
closedatein the last 12 months, the share that reached the stage and were won (hs_is_closed_won === "true"). Closed Won is forced to1.0and Closed Lost to0.0. Values are rounded to 0.1. - Reached means
propertiesWithHistory.dealstagewhen present, otherwise the currentdealstage, and the diff says history was empty. - Stages with fewer than
probabilityMinDeals(default 5) keep their current value and are flagged. The minimum is an implementation choice; the playbook says "too few" without a number. - The patch is
{metadata:{probability}}only.
Owners
- Playbook:
docs/playbooks/owners.md. Deals, tickets and companies use the same rule shape. Companies ignoreassociated_company_owner, and applying them needscrm.objects.companies.writeon the install. - Rules are admin-defined (
portal_settings.assignment_rules) or passed on the dry run (default,associated_company_owner, or per record). With no rule, gaps are reported and nothing is written. - Open and closed records are both in scope. A deactivated (archived) owner is a gap.
- Approval when any one assigned user would receive more than 100 records in the run.
- When gaps exist and
automationis granted, Jev reviews candidate workflows and Grok drafts a proposal in the notes. That proposal is not a write. The write path isworkflows:assignment.
workflows:assignment
- Creates or edits an assignment workflow only after a person approves the diff.
- Scope:
automation. Published create/update pages list that scope, not a separate write scope. Sensitive workflow scopes are not requested. - Allowlist:
POSTandPUT/automation/v4/2027-03-beta/flowsand.../flows/{id}. - The body is a disabled skeleton (
isEnabled: falseon create). An edit does not flipisEnabledfrom false to true. reversibleByApiis false. A created flow cannot be deleted (DELETEis banned). Rollback of a create is a no-op; verify accepts a disabled flow as the before state.assertWritePolicyrejects every other workflow write, including on test portal51359228, unlessWriteClientsetsworkflowWriteApprovedfor this module.
Per-portal opt-out
portal_fix_types defaults to on when no row exists. A false row opts that fix type out for that portal. The global module flag remains an additional kill switch. Both must be on. WriteClient and the runner throw FIX_TYPE_DISABLED when the portal has opted out.