Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Automate and audit HubSpot deal pipeline operations without destroying real pipeline — covering stage automation loops, stale-deal safe-close logic, forecast reconciliation, custom property drift detection, quota dashboard cache-busting, and multi-pipeline duplicate detection. Use when writing or debugging workflow automations that move deals between stages, auditing pipelines for stale or duplicated opportunities, reconciling forecast numbers that disagree across reports, or hardening RevOps da
.claude/skills/jeremylongshore-hubspot-deal-pipeline-automation/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-08 | ✗→✓ | ▲ Improved | 212% | 0% |
| case-01 | ✗→✓ | ▲ Improved | 82% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 115% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 320% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 416% | 0% |
Automate HubSpot deal pipelines without blowing up real revenue. This is not a walkthrough of workflow builder UI — it is the engineering behind six failure modes that silently destroy RevOps data while every dashboard stays green until a rep's quota call reveals the damage.
The six production failures this skill prevents:
which triggers a second workflow enrolled on "Demo Scheduled" that moves it back to "Qualified", which triggers the first workflow again. The deal bounces between stages until HubSpot's 100-action daily workflow execution limit for that deal is exhausted. RevOps sees "workflow failure" alerts but no stage history that makes sense.
than 90 days to clear pipeline bloat. The contact associated with deal #4872 responded to an email this morning. The deal is closed as Lost. The rep calls the contact, who says "I just told someone we were ready to sign." This failure costs quota attainment, not just data quality.
amount, hs_projected_amount, and acustom arr_value property all exist on the same deal. The CRO dashboard queries amount. The ops team queries hs_projected_amount. RevOps built a custom rollup on arr_value. At end of quarter they produce three different forecast numbers for the same deal set with no canonical answer.
deal_source_detail to original_lead_source in the property settings. Every deal-source dashboard built on the old property name silently returns zero. No error is thrown. The board sees a 100% collapse in deal source tracking that is entirely an artifact, not a pipeline signal.
aggregates. A deal closes at 4:47pm. The quota attainment dashboard doesn't reflect it until 8:30pm. A rep who hit quota at EOD is told they're 3% short. This is a 4-hour cache lag in the default reporting stack, and it affects every quota conversation at month-end.
expansion after the initial close. Someone creates a deal in the Expansion pipeline for the same company without deleting the original, or a workflow auto-creates expansion deals. The same revenue is counted in two pipeline forecasts with no cross-reference key linking them.
All API calls use a HubSpot private app token or OAuth access token in the Authorization: Bearer $HUBSPOT_TOKEN header. The hubspot-auth skill covers token caching, rotation, and rate-limit backoff patterns. For deal pipeline automation, the required scopes are listed below.
crm.objects.deals.read, crm.objects.deals.write, crm.schemas.deals.read, crm.schemas.deals.write, crm.associations.read, crm.associations.write, automation
jq installed for shell-based audit scriptsGET /oauth/v1/access-tokens/$TOKEN)
Build in this order. Each section neutralizes one production failure mode.
Workflow loops happen when a stage-change trigger has no memory of what caused the change. The guard injects a sentinel property hs_pipeline_loop_guard (type: string, format: workflowId:epochMs) that any workflow writes immediately before it changes a stage, and reads before it fires. If the guard was written by the same workflow within a debounce window, the workflow aborts.
Create the sentinel property first (one-time setup per portal):
bashcurl -s -X POST "https://api.hubapi.com/crm/v3/properties/deals" \ -H "Authorization: Bearer $HUBSPOT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "hs_pipeline_loop_guard", "label": "Pipeline Loop Guard", "type": "string", "fieldType": "text", "groupName": "dealinformation", "description": "Automation loop sentinel. Format: workflowId:epochMs. Written before any stage transition." }' | jq '{name, label, type}'
TypeScript guard — wrap every stage-change call in this:
typescriptconst DEBOUNCE_MS = 60_000; // 60s window per workflow async function safeStageTransition( dealId: string, workflowId: string, targetStageId: string, token: string, ): Promise<{ transitioned: boolean; reason: string }> { // Read current guard value const deal = await hubspotGet(`/crm/v3/objects/deals/${dealId}`, token, { properties: "dealstage,hs_pipeline_loop_guard", }); const guard: string | null = deal.properties.hs_pipeline_loop_guard ?? null; if (guard) { const [guardedWorkflowId, tsStr] = guard.split(":"); const elapsed = Date.now() - parseInt(tsStr, 10); if (guardedWorkflowId === workflowId && elapsed < DEBOUNCE_MS) { return { transitioned: false, reason: `Loop guard: workflow ${workflowId} already fired ${elapsed}ms ago`, }; } } // Write guard, then transition await hubspotPatch(`/crm/v3/objects/deals/${dealId}`, token, { properties: { hs_pipeline_loop_guard: `${workflowId}:${Date.now()}`, dealstage: targetStageId, }, }); return { transitioned: true, reason: "OK" }; }
hubspotGet and hubspotPatch are thin fetch wrappers — implementations in implementation-guide.md.
Detecting existing loops via stage history audit:
bash# Pull stage change history for a specific deal — look for oscillation DEAL_ID="12345678" curl -s "https://api.hubapi.com/crm/v3/objects/deals/${DEAL_ID}/changelog" \ -H "Authorization: Bearer $HUBSPOT_TOKEN" | \ jq '.results[] | select(.propertyName == "dealstage") | {timestamp, from: .previousValue, to: .currentValue}'
If the output shows the same two stage IDs alternating more than twice in 24 hours, that deal has a live loop. Identify the responsible workflows by checking hs_lastmodifieddate on the deal alongside the workflow enrollment history in HubSpot UI (Contacts → Workflows → History tab for the deal).
Never auto-close a deal without first checking whether the associated contact has recent email activity. The safe-close criterion is: deal older than 90 days AND no inbound email in the last 30 days AND no open tasks.
The full stale_deal_audit.py script (with get_stale_open_deals, has_recent_inbound_email, has_open_tasks, and paginated search) lives in implementation-guide.md § Stale-Deal Audit.
Key points for the search query:
bash# CRM search filter for stale open deals (epoch ms cutoff) CUTOFF=$(python3 -c "import datetime; print(int((datetime.datetime.utcnow()-datetime.timedelta(days=90)).timestamp()*1000))") # filterGroups: createdate LT $CUTOFF AND hs_is_closed EQ false
The script outputs a CSV with safe_to_close boolean. Only rows where safe_to_close=True are candidates for batch close. A separate batch_close_stale.py script (also in the implementation guide) handles the actual close — always requires human review of the CSV before executing.
HubSpot exposes four distinct amount-like fields on a deal. Using the wrong one in a report produces a different number without any error. The canonical selection logic:
| Field | When it is the right number | |---|---| | amount | The rep-entered deal value. Use for quota credit and pipeline value in all CRM views. | | hs_projected_amount | Amount × current-stage probability. Use only for probability-weighted pipeline forecasts. | | hs_deal_stage_probability | The stage probability (0–1). Multiply amount by this yourself if you need weighted values. | | Custom arr_value, mrr_value, etc. | Only if your org has explicitly separated ARR/MRR from deal amount. Validate these exist on the deal before reading them. |
Always request all four amount fields — pick one canonical field per report:
bash# Fetch all four amount fields for a deal and compare curl -s "https://api.hubapi.com/crm/v3/objects/deals/$DEAL_ID" \ -H "Authorization: Bearer $HUBSPOT_TOKEN" \ --data-urlencode "properties=amount,hs_projected_amount,hs_deal_stage_probability,arr_value" | \ jq '.properties | {amount, projected: .hs_projected_amount, probability: .hs_deal_stage_probability, arr: .arr_value}'
Discrepancy rule: if |hs_projected_amount - (amount × hs_deal_stage_probability)| / amount > 0.05, hs_projected_amount is stale. Force recalculation by patching dealstage to the current stage value — HubSpot recomputes on every stage write.
The full Python forecast_reconcile.py script (with paginated open-deal search and --fix flag for bulk recalculation) is in implementation-guide.md § Forecast Reconciliation.
When a deal property used in a dashboard is renamed or deleted, dashboards silently return zero without throwing an error. Run this check before every sprint that touches property schema, and wire it into a nightly CI job.
bash#!/usr/bin/env bash # check-deal-properties.sh # Usage: HUBSPOT_TOKEN=... ./check-deal-properties.sh properties-in-use.txt # properties-in-use.txt: one property name per line (from dashboard/report definitions) set -euo pipefail PROPERTIES_FILE="${1:-properties-in-use.txt}" TOKEN="${HUBSPOT_TOKEN:?HUBSPOT_TOKEN required}" # Fetch all deal properties from the portal ALL_PROPS=$( curl -s "https://api.hubapi.com/crm/v3/properties/deals" \ -H "Authorization: Bearer $TOKEN" | \ jq -r '.results[].name' ) echo "Checking properties against portal schema..." MISSING=0 while IFS= read -r prop; do [[ -z "$prop" ]] && continue if ! echo "$ALL_PROPS" | grep -qx "$prop"; then echo "MISSING: $prop" MISSING=$((MISSING + 1)) fi done < "$PROPERTIES_FILE" if [[ $MISSING -gt 0 ]]; then echo "" echo "ERROR: $MISSING properties used in reports do not exist in this portal." echo "Review recent property renames/deletions in Settings → Properties." exit 1 else echo "All $( wc -l < "$PROPERTIES_FILE" | tr -d ' ' ) properties found in portal schema." fi
Generate properties-in-use.txt by extracting all property names from your report/dashboard definitions. The list must be maintained alongside any schema change — wire check-deal-properties.sh into your deployment pipeline so that property renames fail the deploy before they break dashboards in production.
HubSpot's native reporting layer caches deal stage aggregates. The cache TTL in the standard reporting stack is up to 4 hours. For real-time quota views at month-end, bypass the reporting layer and query the CRM API directly with a current-state search.
typescriptinterface QuotaSnapshot { closedWon: number; // sum of amount for ClosedWon deals in current period closedWonCount: number; openPipeline: number; // sum of amount for open deals asOf: string; // ISO timestamp of this query } async function liveQuotaSnapshot( pipelineId: string, closedWonStageId: string, ownerId: string, token: string, periodStartMs: number, ): Promise<QuotaSnapshot> { // Closed Won this period const closedWonSearch = { filterGroups: [ { filters: [ { propertyName: "pipeline", operator: "EQ", value: pipelineId }, { propertyName: "dealstage", operator: "EQ", value: closedWonStageId }, { propertyName: "hubspot_owner_id", operator: "EQ", value: ownerId }, { propertyName: "closedate", operator: "GTE", value: String(periodStartMs) }, ], }, ], properties: ["amount"], limit: 100, }; const cwResp = await hubspotSearch("/crm/v3/objects/deals/search", closedWonSearch, token); const closedWon = cwResp.results.reduce( (sum: number, d: any) => sum + (parseFloat(d.properties.amount) || 0), 0, ); // Open pipeline (not closed) — for forecast view const openSearch = { filterGroups: [ { filters: [ { propertyName: "pipeline", operator: "EQ", value: pipelineId }, { propertyName: "hs_is_closed", operator: "EQ", value: "false" }, { propertyName: "hubspot_owner_id", operator: "EQ", value: ownerId }, ], }, ], properties: ["amount"], limit: 100, }; const openResp = await hubspotSearch("/crm/v3/objects/deals/search", openSearch, token); const openPipeline = openResp.results.reduce( (sum: number, d: any) => sum + (parseFloat(d.properties.amount) || 0), 0, ); return { closedWon, closedWonCount: cwResp.total, openPipeline, asOf: new Date().toISOString(), }; }
hubspotSearch is the same helper defined in section 1. Serve this function from your internal ops dashboard and poll it on page load instead of embedding a HubSpot report iframe. The CRM API reflects stage changes within seconds of them happening. The full polling service (Express + 30-second in-memory cache
§ Quota Dashboard Cache-Busting Pattern.
The canonical deduplication key is company_id + closedate_month + deal_type_custom_field. Without this cross-reference, the same opportunity appears in two pipelines and inflates ARR forecast.
The full findDuplicateDeals() and searchAllOpenDeals() TypeScript implementations live in implementation-guide.md § Multi-Pipeline Duplicate Detection. The algorithm:
hs_is_closed EQ false)GET /crm/v3/objects/deals/$DEAL_ID/associations/companiescompanyId — companies with deals in more than one pipeline are duplicatesOnce duplicates are identified, link them with a cross-reference association before closing one:
bash# Link a new-business deal to its expansion deal (association type 5 = deal-to-deal) curl -s -X PUT \ "https://api.hubapi.com/crm/v4/objects/deals/${NB_DEAL_ID}/associations/deals/${EXP_DEAL_ID}/5" \ -H "Authorization: Bearer $HUBSPOT_TOKEN" | jq '{fromObjectId, toObjectId, associationTypes}'
| HTTP Status | Error | Root Cause | Action | |---|---|---|---| | 400 BAD_REQUEST | INVALID_FILTER | Search filter uses a property name that doesn't exist or uses wrong operator for type | Validate property names via GET /crm/v3/properties/deals before building filter; use EQ for enum, GTE/LT for date/number | | 400 BAD_REQUEST | PROPERTY_DOESNT_EXIST | PATCH payload includes a property that was deleted or renamed | Run check-deal-properties.sh against the payload property list; catch and surface the missing property name to RevOps | | 403 FORBIDDEN | MISSING_SCOPES | Token lacks crm.objects.deals.write or automation scope | Verify scopes via GET /oauth/v1/access-tokens/$TOKEN; re-issue private app token with correct scopes | | 404 NOT_FOUND | OBJECT_NOT_FOUND | Deal ID in URL doesn't exist, or association target object doesn't exist | Confirm deal exists with GET /crm/v3/objects/deals/$DEAL_ID before writing; handle 404 in batch loops gracefully | | 409 CONFLICT | OBJECT_ALREADY_EXISTS | Batch create includes a deal that matches a unique property value | Deduplicate input set; use batch update instead of create for existing deals | | 429 TOO_MANY_REQUESTS | RATE_LIMIT | Exceeded 100 API calls/10s (Professional/Enterprise) or 250K/500K daily | Read Retry-After header and pause; add 100ms sleep between pages in search loops; use batch endpoints to collapse N updates into one call |
bashMONTH_START=$(python3 -c "import datetime; now=datetime.datetime.utcnow(); print(int(datetime.datetime(now.year,now.month,1).timestamp()*1000))") MONTH_END=$(python3 -c "import datetime; now=datetime.datetime.utcnow(); import calendar; end=calendar.monthrange(now.year,now.month)[1]; print(int(datetime.datetime(now.year,now.month,end,23,59,59).timestamp()*1000))") curl -s -X POST "https://api.hubapi.com/crm/v3/objects/deals/search" \ -H "Authorization: Bearer $HUBSPOT_TOKEN" \ -H "Content-Type: application/json" \ -d "{ \"filterGroups\": [{ \"filters\": [ {\"propertyName\": \"closedate\", \"operator\": \"GTE\", \"value\": \"$MONTH_START\"}, {\"propertyName\": \"closedate\", \"operator\": \"LTE\", \"value\": \"$MONTH_END\"}, {\"propertyName\": \"hs_is_closed\", \"operator\": \"EQ\", \"value\": \"false\"} ] }], \"properties\": [\"dealname\",\"amount\",\"dealstage\",\"pipeline\",\"hubspot_owner_id\"], \"sorts\": [{\"propertyName\": \"amount\", \"direction\": \"DESCENDING\"}], \"limit\": 50 }" | jq '.results[] | {id, name: .properties.dealname, amount: .properties.amount, stage: .properties.dealstage}'
bash# deals-to-update.json: {"id":"12345","properties":{"dealstage":"appointmentscheduled"}} jq -n --argjson ids '["11111","22222","33333"]' \ '{inputs: [$ids[] | {id: ., properties: {dealstage: "qualifiedtobuy"}}]}' \ > batch_payload.json curl -s -X POST "https://api.hubapi.com/crm/v3/objects/deals/batch/update" \ -H "Authorization: Bearer $HUBSPOT_TOKEN" \ -H "Content-Type: application/json" \ -d @batch_payload.json | jq '{status, numResults: (.results | length), errors: (.errors // [])}'
bashcurl -s "https://api.hubapi.com/crm/v3/pipelines/deals" \ -H "Authorization: Bearer $HUBSPOT_TOKEN" | \ jq '.results[] | {pipelineId: .id, label: .label, stages: [.stages[] | {stageId: .id, label: .label, probability: .metadata.probability}]}'
bashcurl -s "https://api.hubapi.com/crm/v3/properties/deals" \ -H "Authorization: Bearer $HUBSPOT_TOKEN" | \ jq '[.results[] | select(.hubspotDefined == false) | {name, label, type, fieldType, groupName}]'
Working with this skill produces:
hs_pipeline_loop_guard sentinel propertycreated in the portal, and a TypeScript safeStageTransition() wrapper that prevents any workflow from re-triggering itself within a 60-second window
stale_deals.csv with a safe_to_close booleancolumn; only rows with safe_to_close=True are candidates for auto-close workflows
hs_projected_amount diverges from amount × stage_probability by more than 5%, identifying deals where stage probability was changed without recalculating projections
not present in the portal's current schema
closedWon, closedWonCount, openPipeline, and asOf with sub-second freshness versus the 4-hour reporting cache
one pipeline, with deal IDs to inspect and cross-reference associations to create before merging
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-08 | fail→pass | 26,820 | 16,667 | -38% | 1 | 1 | 0% | 2,503 | 7,803 | +212% | 0 | 0 | — |
case-07 | pass→pass | 19,140 | 15,896 | -17% | 1 | 1 | 0% | 2,382 | 7,620 | +220% | 0 | 0 | — |
case-01 | fail→pass | 25,861 | 22,040 | -15% | 1 | 1 | 0% | 4,326 | 7,871 | +82% | 0 | 0 | — |
case-02 | fail→fail | 33,141 | 31,943 | -4% | 1 | 1 | 0% | 5,724 | 11,413 | +99% | 0 | 0 | — |
case-03 | fail→pass | 25,450 | 12,598 | -50% | 1 | 1 | 0% | 3,829 | 8,219 | +115% | 0 | 0 | — |
case-04 | fail→pass | 21,492 | 8,956 | -58% | 1 | 1 | 0% | 1,543 | 6,481 | +320% | 0 | 0 | — |
case-05 | fail→pass | 6,824 | 8,776 | +29% | 1 | 1 | 0% | 1,249 | 6,441 | +416% | 0 | 0 | — |
case-06 | pass→pass | 15,842 | 14,400 | -9% | 1 | 1 | 0% | 2,043 | 7,574 | +271% | 0 | 0 | — |
case-09 | pass→pass | 14,899 | 17,315 | +16% | 1 | 1 | 0% | 3,048 | 8,181 | +168% | 0 | 0 | — |
case-10 | fail→pass | 15,325 | 12,465 | -19% | 1 | 1 | 0% | 1,908 | 7,216 | +278% | 0 | 0 | — |
case-11 | fail→fail | 20,406 | 13,277 | -35% | 1 | 1 | 0% | 3,886 | 8,096 | +108% | 0 | 0 | — |
case-12 | fail→pass | 17,533 | 21,837 | +25% | 1 | 1 | 0% | 2,597 | 8,684 | +234% | 0 | 0 | — |
case-13 | fail→pass | 18,920 | 17,361 | -8% | 1 | 1 | 0% | 2,373 | 8,155 | +244% | 0 | 0 | — |
case-19 | fail→pass | 26,624 | 12,470 | -53% | 1 | 1 | 0% | 3,092 | 7,999 | +159% | 0 | 0 | — |
case-14 | pass→pass | 13,224 | 10,982 | -17% | 1 | 1 | 0% | 1,404 | 6,608 | +371% | 0 | 0 | — |
case-15 | pass→pass | 15,184 | 6,763 | -55% | 1 | 1 | 0% | 2,023 | 7,017 | +247% | 0 | 0 | — |
case-16 | pass→pass | 9,157 | 3,750 | -59% | 1 | 1 | 0% | 1,508 | 6,420 | +326% | 0 | 0 | — |
case-17 | fail→pass | 7,250 | 3,407 | -53% | 1 | 1 | 0% | 1,260 | 6,367 | +405% | 0 | 0 | — |
case-18 | fail→pass | 18,542 | 25,625 | +38% | 1 | 1 | 0% | 2,233 | 7,416 | +232% | 0 | 0 | — |
case-20 | fail→fail | 23,547 | 19,770 | -16% | 1 | 1 | 0% | 3,642 | 9,562 | +163% | 0 | 0 | — |
case-21 | fail→fail | 15,068 | 18,957 | +26% | 1 | 1 | 0% | 3,016 | 8,384 | +178% | 0 | 0 | — |
case-22 | fail→fail | 23,506 | 24,183 | +3% | 1 | 1 | 0% | 2,765 | 9,454 | +242% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +50 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.