> ## Documentation Index
> Fetch the complete documentation index at: https://docs.parmanasystems.com/llms.txt
> Use this file to discover all available pages before exploring further.

# HubSpot

> Policy-gated updates to a HubSpot Deal's dealstage and amount. Hermetic by default, proven live against a real HubSpot developer/test account.

<Info>
  **\[AVAILABLE]**, hermetic test suite plus a live run against a real HubSpot developer/test
  account. `packages/connector-hubspot/`, `docs/CLAIMS.md` §3.10. This is the second connector
  in this codebase (after [Razorpay](/integrations/razorpay)) that talks to a real external
  system, everything else registered by default (`vendor-payment`) or offered as a reference
  (SAP, Salesforce, Workday, Oracle) is a mock, see [The gateway](/concepts/the-gateway).
</Info>

## What this connector does

Updates one property, or two, on one HubSpot object type: a Deal's `dealstage`, optionally
alongside `amount`, in a single `PATCH /crm/v3/objects/deals/{dealId}` call. Nothing else.
It does not touch Contacts or Companies, does not delete or archive deals, has no webhook or
event-driven trigger, and does not perform multi-object writes, see \[FUTURE] below. It does
not change anything about the Execution Gateway, how policies are evaluated (see [Policies
and the decision](/concepts/policies-and-the-decision)), Replay, Receipt Generation,
Verification, or the REST API, those work exactly as documented elsewhere on this site.

`@parmana/connector-hubspot` is a standalone package, not a subdirectory of
`@parmana/connector-sdk` like the four reference mocks and Razorpay, it depends on
`@parmana/connector-sdk`'s `Connector` contract the same way they do.

## Deny-by-default at the property level

A `hubspot:deal-update` request naming any property other than `dealstage` or `amount` is
refused before any network call, not silently dropped:

```typescript theme={null}
// packages/connector-hubspot/src/HubSpotConnector.ts (abridged, real source)
const disallowedKeys = Object.keys(request.parameters).filter(
  (key) => key !== "dealId" && !HUBSPOT_ALLOWED_DEAL_UPDATE_PROPERTIES.includes(key),
);
if (disallowedKeys.length > 0) {
  throw new Error(
    `HubSpotConnector refuses to update unsupported deal propert${disallowedKeys.length === 1 ? "y" : "ies"} ` +
      `${disallowedKeys.map((key) => `"${key}"`).join(", ")}. Only dealstage, amount are touchable this milestone.`,
  );
}
```

Silently dropping an unsupported property could mask a caller's real intent behind an update
that quietly did less than requested, refusing outright is the safer failure mode.

## The policy model

`policies/hubspot-deal-update/1.0.0/policy.json` rejects a proposed `dealstage` transition
unless it moves strictly forward through a fixed default stage order
(`appointmentscheduled` → `qualifiedtobuy` → `presentationscheduled` →
`decisionmakerboughtin` → `contractsent` → `closedwon`), with one explicit exception: moving
to `closedlost` is allowed from any non-terminal stage. Any move backward, any move out of a
terminal stage (`closedwon` or `closedlost`), and any move to or from a stage id the order
doesn't recognize, are all rejected. Separately, an `amount` change is rejected if its
absolute delta from the deal's current amount exceeds a configured threshold (10,000, in the
deal's own currency units) unless the caller declares `preAuthorizedForAmountChange: true`.

<Warning>
  **`preAuthorizedForAmountChange` is a caller-declared signal this milestone does not
  independently verify.** There is no separately signed approval artifact behind it yet, a
  caller asserting `true` is taken on faith, the same way an unbound signal on any other
  policy is. Absent, it defaults to `false`, the safe default, see \[FUTURE] below.
</Warning>

`boundSignals` is present from this policy's first version, not added after a live
demonstration of a signal/intent mismatch the way `razorpay-refund/1.0.0` was (see
[Razorpay](/integrations/razorpay) and [Policies and the decision](/concepts/policies-and-the-decision)
for that history):

```json theme={null}
// policies/hubspot-deal-update/1.0.0/policy.json (excerpt, real source)
"boundSignals": {
  "proposedDealStage": "parameters.dealstage",
  "proposedAmount": "parameters.amount"
}
```

A caller's declared `proposedDealStage`/`proposedAmount` signals must exactly equal
`intent.parameters.dealstage`/`intent.parameters.amount`, checked by `SignalIntentBinder`
before policy evaluation ever runs, so the facts the policy approved describe the same
update that actually executes, not just a payload that happens to look correct in
isolation.

## Try it: an authorized update and a denied one

An authorized request, `dealstage` moving forward one step, and a denied one, an attempted
backward move, sent to the real, production-wired `POST /execute` route in a hermetic test
process (`packages/api/tests/integration/hubspot-deal-update.integration.test.ts`), captured
from an actual local run, 2026-07-31:

```json theme={null}
// Authorized: appointmentscheduled -> qualifiedtobuy
{
  "intent": {
    "action": "hubspot:deal-update",
    "target": "hubspot://deals/9001",
    "parameters": { "dealId": "9001", "dealstage": "qualifiedtobuy" }
  },
  "policy": { "name": "hubspot-deal-update", "version": "1.0.0", "schemaVersion": "1.0.0" },
  "signals": {
    "currentDealStage": "appointmentscheduled",
    "proposedDealStage": "qualifiedtobuy",
    "dealStageChangeRequested": true,
    "dealStageTransitionAllowed": true,
    "amountChangeRequested": false,
    "amountDeltaAbs": 0,
    "amountChangeExceedsThreshold": false,
    "preAuthorizedForAmountChange": false
  }
}
```

```
Response status: 200
```

```json theme={null}
// Response body, trimmed to the fields this connector adds — the full shape is
// the same ExecutionTrustRecord every route returns, see
// [Execution Trust Record](/concepts/execution-trust-records)
{
  "executions": [{
    "decision": {
      "outcome": "APPROVED",
      "reason": "Deal update authorized. The dealstage transition (if any) is on an allowed path, and the amount change (if any) is within threshold or has been pre-authorized."
    },
    "evidence": {
      "attributes": {
        "connector": {
          "connectorId": "hubspot",
          "capability": "hubspot:deal-update",
          "responseSummary": {
            "success": true,
            "metadata": {
              "deal": {
                "id": "9001",
                "properties": { "dealstage": "qualifiedtobuy", "amount": "5000", "pipeline": "default" }
              },
              "bearerRedacted": "pat-na1-0000..."
            }
          }
        }
      }
    }
  }]
}
```

The denied request, same deal, an attempted backward move (`qualifiedtobuy` back to
`appointmentscheduled`), rejected before the connector is ever dispatched, zero HubSpot API
calls, confirmed by a `fetch` spy in the underlying test, not just by inspecting the
response:

```json theme={null}
{
  "intent": {
    "action": "hubspot:deal-update",
    "target": "hubspot://deals/9001",
    "parameters": { "dealId": "9001", "dealstage": "appointmentscheduled" }
  },
  "policy": { "name": "hubspot-deal-update", "version": "1.0.0", "schemaVersion": "1.0.0" },
  "signals": {
    "currentDealStage": "qualifiedtobuy",
    "proposedDealStage": "appointmentscheduled",
    "dealStageChangeRequested": true,
    "dealStageTransitionAllowed": false,
    "amountChangeRequested": false,
    "amountDeltaAbs": 0,
    "amountChangeExceedsThreshold": false,
    "preAuthorizedForAmountChange": false
  }
}
```

```json theme={null}
// Response status: 403
{
  "error": "Execution rejected: Deal update rejected because the requested dealstage transition is not on an allowed forward path.",
  "code": "POLICY_DENIED"
}
```

## Try it without touching HubSpot at all

```bash theme={null}
npx tsx examples/tutorials/63-hubspot-deal-update/run.ts
```

No environment variables, no network calls. This spins up an in-process
`MockHubSpotServer` with a fake test-mode token and runs the real `HubSpotDealUpdateService`
against it, loading the real policy above from disk. Real output from a run against this
exact code:

```
==================================================
Tutorial 63 - HubSpot Deal Update Connector
==================================================

Outcome 1 - Approved and Executed
--------------------------------------------------
Approved            : true
Applied Deal Stage  : qualifiedtobuy
Applied Amount      : 5500
Policy Reason       : Deal update authorized. The dealstage transition (if any) is on an allowed path, and the amount change (if any) is within threshold or has been pre-authorized.
Token (redacted)    : pat-na1-demo...

Outcome 2 - Denied by Policy
--------------------------------------------------
Approved      : false
Matched Rule  : reject-stage-transition-not-allowed
Reason        : Deal update rejected because the requested dealstage transition is not on an allowed forward path.

Outcome 3 - Replay Returns Recorded Result
--------------------------------------------------
Replayed              : true
Same Receipt Returned : true
Deal Stage On HubSpot : qualifiedtobuy (unchanged from qualifiedtobuy)

Outcome 4 - Tamper Rejected
--------------------------------------------------
Rejected      : true
Reason        : Execution Gateway rejected request: failed checks [businessTransactionHashMatches, nonceUnseen]. businessTransactionHash mismatch: expected ..., got ....

==================================================
Tutorial completed successfully.
==================================================
```

Hash values are random per run, everything else in the output is stable. Covers `dealstage`/
`amount` update only, nothing else demonstrated here.

## Setup

Create a **Private App** in the target HubSpot account (Settings → Integrations → Private
Apps), scoped to exactly two CRM scopes:

* `crm.objects.deals.read`
* `crm.objects.deals.write`

No broader scope is needed, and none is checked for by this connector, HubSpot itself will
reject a request if the token's scopes are insufficient. The Private App generates one
token, that's the credential this connector reads, there is no separate "key ID" the way
Razorpay's Basic-auth credential has two parts.

## Configuration

| Variable                         | Purpose                                                                                                                                       |
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `HUBSPOT_PRIVATE_APP_TOKEN`      | Production credential. Unset outside test mode means the connector is not registered at all, degrades gracefully rather than failing to boot. |
| `TEST_HUBSPOT_PRIVATE_APP_TOKEN` | Test-mode override, read directly when `NODE_ENV=test`, no intermediate bridge variable.                                                      |
| `ALLOW_LIVE_HUBSPOT`             | Opt-in for the gated live suite. Unset, that suite skips cleanly, does not run in a default `npm test`.                                       |
| `TEST_HUBSPOT_DEAL_ID`           | A real test deal in the same account, required only for the live suite's mutating case. Absent, only that one case skips.                     |
| `HUBSPOT_BASE_URL`               | Test seam only, points the connector at a mock server. Never set in production.                                                               |

## What's proven where, precisely

This is the part worth reading in full before deciding how much to trust this connector.

* **`packages/connector-hubspot/tests/unit/`** (42 tests) hit `MockHubSpotServer`, not the
  real network. They run on every `npm test`, no gate required. This covers the full
  authorize → verify → execute → confirm chain, every policy rule branch, replay with zero
  second calls, `businessTransactionHash` tamper rejection, and the placeholder-credential
  guard against a real-shaped endpoint.
* **`packages/api/tests/integration/hubspot-deal-update.integration.test.ts`** (3 tests)
  drives the same real, production-wired `POST /execute` route this page's examples above
  are captured from, against `MockHubSpotServer`. A policy denial here is proven to make zero
  HubSpot calls two ways: the mock server's own state is confirmed unchanged, and a `fetch`
  spy confirms literally nothing reached its base URL.
* **`packages/api/tests/integration/hubspot-live.integration.test.ts`** (3 tests) is the
  only test in this codebase that calls the real HubSpot API for this connector. Gated
  behind `ALLOW_LIVE_HUBSPOT=1` plus a real `TEST_HUBSPOT_PRIVATE_APP_TOKEN` (must start with
  `pat-`, checked before any network call), does not run in a default `npm test`. Run to
  completion against a real HubSpot developer/test account: a reachability check against a
  non-existent deal id, a policy denial proven to make zero real HubSpot calls, and a
  non-destructive amount change against a real test deal, read live, nudged by a small
  within-threshold delta, verified via an independent live read, then reverted to its
  original value in the same run, safe to repeat, unlike an irreversible action. See
  `docs/CLAIMS.md` §3.10 for the full trace, including one test-fixture bug this live run
  caught and how it was fixed.

## \[FUTURE]

* **Contacts and Companies.** No implementation exists. This connector covers Deal
  `dealstage`/`amount` update only.
* **Delete or archive a deal.** No implementation exists, deny-by-default this milestone
  touches only `dealstage`/`amount` on an existing deal.
* **A webhook or event-driven trigger.** No implementation exists. This connector is
  request-response only, there is no asynchronous confirmation loop the way Razorpay's
  refund lifecycle has one, see [Verify Razorpay webhooks](/guides/verify-razorpay-webhooks)
  for what that took.
* **Multi-object transactions.** No implementation exists, each `hubspot:deal-update` call
  is a single Deal write.
* **Per-pipeline stage-transition rules.** The forward-only stage order above is one global
  order, not keyed by a deal's own `pipeline` property. `isHubSpotStageTransitionAllowed`
  accepts a `stageOrder` override, nothing wires it to a pipeline yet.
* **Independent verification of `preAuthorizedForAmountChange`.** It's an unverified
  caller-declared signal today, see the warning above.

## Next

<CardGroup cols={2}>
  <Card title="Connector Development Guide" icon="plug" href="/integrations/connector-development-guide">
    The pattern this connector follows, and how to build the next one.
  </Card>

  <Card title="Policies and the decision" icon="scale-balanced" href="/concepts/policies-and-the-decision">
    What `boundSignals` closes, and why it's here from this policy's first version.
  </Card>
</CardGroup>
