Skip to main content
[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) that talks to a real external system; everything else offered as a reference (SAP, Salesforce, Workday, Oracle) is a mock, and no other capability registers by default at all, see The gateway.

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), 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:
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 (appointmentscheduledqualifiedtobuypresentationscheduleddecisionmakerboughtincontractsentclosedwon), 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.
preAuthorizedForAmountChange is independently verified, not taken on faith — but nothing can pass that check in this deployment as configured, only fail it. HubSpotSignalStateVerifier (packages/connector-hubspot/src/HubSpotSignalStateVerifier.ts), constructed with a real ApprovalVerifier unconditionally in packages/api/src/bootstrap/createHubSpotSignalStateVerifier.ts (“approvalVerifier is always supplied here, never omitted”), checks a caller’s declared true against a real, independently-issued, Ed25519-signed Approval Artifact carried in signals.approvalArtifact — issuer identity, signature validity, expiry, capability/resource scope, and single-use nonce consumption, all verified before the claim is trusted. A missing, expired, wrong-scope, replayed, or unsigned artifact is treated as false regardless of what the caller declared, the same fail-closed default as before. The gap that remains: createApprovalIssuerRegistry.ts’s trusted-issuer list ships empty by default — no real business-approver key is provisioned in this deployment, so every preAuthorizedForAmountChange claim is rejected today, genuine artifact or not, until an operator adds one. The mechanism is real, tested, and unconditionally wired in; it just has nothing to trust yet. See docs/CLAIMS.md §3.10’s TD-23/Phase 3C update for the full trace.
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 and Policies and the decision for that history):
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:
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:

Try it without touching HubSpot at all

No environment variables, no network calls. This spins up an in-process MockHubSpotServer with a placeholder test-mode token and drives one deal update through the exact same production bootstrap chain server.ts calls (createExecutionSystemcreateApplication), pointed at the mock server via the connector’s test seam, loading the real policy above from disk. Real output from a run against this exact code:
This one script covers the approved path only, tutorials 70-72 in the same directory cover policy denial, signal-state verification, and the signed approval-artifact check individually, see Tutorials for the full list. 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

What’s proven where, precisely

This is the part worth reading in full before deciding how much to trust this connector.
  • 43 unit tests across two packages hit MockHubSpotServer, not the real network, and run on every npm test, no gate required: packages/connector-hubspot/tests/unit/ (31 tests, including HubSpotSignalStateVerifier.test.ts’s Approval Artifact verification suite — a matching artifact, an unknown issuer, a scope-escalated amount, a deal-id mismatch, and a replayed nonce, each independently rejected or approved) and packages/execution-gateway/tests/unit/hubspot-connector.test.ts (12 tests, moved here in the Phase 1C migration). Together these cover 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 (6 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 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.
  • A provisioned, real approval issuer. Independent verification of preAuthorizedForAmountChange is built, tested, and unconditionally wired in (see the warning above), but the trusted-issuer registry ships empty — no business can actually produce an artifact this deployment accepts until an operator provisions one.

Next

Connector Development Guide

The pattern this connector follows, and how to build the next one.

Policies and the decision

What boundSignals closes, and why it’s here from this policy’s first version.