Skip to article

Procurement data · CRM integration

Bring SAM.gov opportunities into HubSpot and preserve your sales team's work.

Build a SAM.gov to HubSpot import with stable notice IDs, explicit field ownership and deadline updates. Download the Python reference code and tested local demo.

Published September 9, 20269 min readBy Daniel

A SAM.gov HubSpot integration needs a stable notice key and a clear split between procurement facts and sales decisions. Import selected notices, keep their source fields current, and let the sales team retain ownership of stage, owner, forecast amount and expected close date.

This tutorial includes Python reference code, a field-mapping CSV and a runnable example of an unchanged rerun followed by a deadline update. It starts with collected opportunity records. The SAM.gov Opportunities API guide covers obtaining them.

Validation scope, September 9, 2026: the example passes eight local tests using explicitly synthetic notices and an in-memory deal store. The HubSpot HTTP adapter follows the documented endpoints but has not been run against a live HubSpot account. The deadline change below is a controlled test, not an observed government amendment. Use the account acceptance steps before relying on it for live work.

Choose what one deal represents

For this example, one SAM.gov notice ID maps to one HubSpot deal. A repeat observation of that notice updates the same deal. A different notice ID remains a separate record, even if its title or solicitation number looks similar.

That is a deliberately narrow workflow. A team pursuing one procurement across several related notices may instead need a pursuit record with a notice relationship table. Do not merge notices by title to approximate that model. Define the relationship explicitly; the government contract tracker guide explains the wider tracking architecture.

The demo admits a new deal only when all three conditions hold:

  • The source active value is Yes.
  • naicsCode is exactly 541512, retained as text.
  • The notice type is Solicitation or Combined Synopsis/Solicitation.

This is a sample routing rule, not a recommendation to pursue every matching notice. It does not check eligibility, attachments or whether the response deadline has passed. A person still qualifies the opportunity.

Map source facts without overwriting sales work

Use dedicated source properties so that an amended notice cannot silently rewrite a salesperson's decisions. In this design, even the source title lives separately from the editable deal name.

| Source or configuration | HubSpot field | Update rule | |---|---|---| | noticeId | wt_sam_notice_id | Unique external key | | title | wt_sam_title | Refresh source title | | fullParentPathName | wt_sam_agency | Retain agency path as text | | naicsCode | wt_sam_naics | Preserve code as text | | type, active | wt_sam_notice_type, wt_sam_active | Refresh source state | | responseDeadLine | wt_sam_deadline_raw | Preserve original deadline text | | uiLink | wt_sam_url | Retain the source link | | Initial configuration | pipeline, dealstage | Set on creation only | | Initial title | dealname | Set on creation only |

The importer never writes amount, closedate or hubspot_owner_id. A public procurement amount is not automatically your expected revenue, and a bid deadline is not your forecast close date. Likewise, a procurement notice type does not determine your sales stage.

An explicit null source value clears its mapped field. An omitted source field leaves the existing value alone. This lets the adapter distinguish a declared empty deadline from a partial input that did not supply that field.

The deadline stays in a text property for this reference implementation. That avoids inventing a timezone when a source value is ambiguous. If you add a sortable deadline field, retain the raw text alongside it and normalize only values with a justified timezone. HubSpot distinguishes date-only values from UTC datetime values; choose the appropriate property type for the destination. (HubSpot property types)

Run the local import and deadline change

Extract the ZIP and open a terminal in its folder. Python 3.10 or later is sufficient; there are no third-party dependencies.

python -B demo.py
python -B -m unittest test_importer.py

The three fixture notices are fictional. One matches the admission rule, one has another NAICS code, and one is inactive. Their links point to the SAM.gov homepage rather than pretending to identify real notices.

The demo then performs three runs:

| Run | Input | Result in the local store | |---|---|---| | 1 | Three synthetic notices | One create, two skips | | 2 | Identical input | One unchanged record, two skips; no writes | | 3 | Same notice ID, revised deadline | One source-field update; still one deal |

Between runs two and three, the demo changes the deal's sales stage to proposal, assigns a demo owner, sets an amount and gives it a team-written name. The third run preserves all four values while moving the raw response deadline from 2026-09-18T17:00:00-04:00 to 2026-09-25T17:00:00-04:00.

These assertions prove the mapping and update rules in the local model. They do not prove HubSpot account permissions, field configuration, automation behavior or live duplicate prevention. The download includes the test code so you can inspect the distinction.

Prepare a HubSpot test account

Create the eight wt_sam_* custom deal properties in the mapping as single-line text. Give wt_sam_notice_id unique values using hasUniqueValue: true. The importer checks the property definitions; it does not create account configuration.

The HubSpot properties guide documents custom properties and unique identifiers. Confirm the account supports the definitions you need before proceeding. Resolve any pre-existing duplicates deliberately instead of adding another ID property and hoping they disappear.

Choose an initial stage in a test pipeline. Supply internal pipeline and stage IDs, not their display labels. New deals receive the initial name and stage, while later changes patch the existing record by its HubSpot ID. (HubSpot deals API)

Have an account administrator configure a server-side app token with deal read/write access and the schema and pipeline read access required by the preflight calls. Keep it in HUBSPOT_ACCESS_TOKEN through your environment or secret manager. The package contains no credentials.

Once configured, the following command reads the account and prints a plan:

python -B importer.py synthetic-notices.json --pipeline YOUR_PIPELINE_ID --stage YOUR_STAGE_ID

Inspect the planned property changes. To apply them in that test account, add --apply:

python -B importer.py synthetic-notices.json --pipeline YOUR_PIPELINE_ID --stage YOUR_STAGE_ID --apply

The live path is ready for account testing, but its acceptance remains outstanding. Do not send the synthetic fixtures to a production pipeline. For real input, replace the fixture with an object containing the collected opportunitiesData array and adapt the admission rule in synchronize.

Create once and patch only changed source fields

The importer reads a deal using wt_sam_notice_id as its unique identifier. If it exists, the script compares source-owned fields and sends a patch only when values differ. If it does not exist and passes admission, the script creates it with the initial pipeline and stage.

notice ID → read existing deal by unique source key
  found → compare source properties → patch changed properties
  absent → apply admission filter → create with initial sales stage

HubSpot also documents a batch upsert endpoint, which identifies records through a unique property. This example uses separate read, create and update calls to keep creation defaults out of subsequent updates. It pins the documented v3 interface rather than silently mixing it with the dated API paths in the latest reference.

Run one writer for this small recipe. A concurrent writer can create a record after another worker reads it as absent. The unique property is a backstop, but the integration must still reconcile the conflict. An archived or merged CRM record also requires an explicit policy.

On an HTTP error the script stops. Earlier successful writes are not rolled back. After an uncertain create response, read by the same external key before retrying. The recipe does not implement a durable job log, automatic backoff, batch recovery or distributed locking; add those before scheduling it at scale.

Keep watching notices after they leave your filter

Admission and maintenance need different rules. An inactive notice should not become a new deal under this example's filter. A notice already linked to a deal must still receive its inactive status.

The adapter therefore looks up the notice before applying the new-deal filter. That only helps if the collector continues to provide the notice. Maintain a watched-ID collection and refresh previously admitted records even when they no longer appear in a filtered search.

GSA describes the public Opportunities API as returning the latest active version and points to Data Services for all versions. A posted-date query alone is not a complete change feed. Use the collection and reconciliation approach in the SAM.gov API guide, and retain your raw observations outside HubSpot. (GSA API documentation)

The adapter assumes current, reconciled snapshots processed in order. It has no source-version watermark. An older snapshot could overwrite a newer deadline, so reject stale inputs upstream. A later retrieval time alone does not prove the underlying source version is newer.

Absence from a fetch does not close or delete a deal. It can reflect pagination, a source outage or a changed filter. Even an explicit inactive status updates only the source property here; your team decides what it means for the pursuit.

Verify the integration in your account

Before scheduling live delivery, complete this account-level check:

  1. Run a preview and confirm the selected pipeline, stage and mapped fields.
  2. Apply the fixture in a test account; record the created HubSpot deal ID.
  3. Rerun unchanged input and verify the same ID, one deal and no planned update.
  4. Change the sales stage, owner, amount and deal name manually.
  5. Change only the fixture deadline, rerun and verify those sales fields survive.
  6. Make the known fixture inactive and confirm its source status updates without closing it.
  7. Inspect account workflows and audit history for unintended downstream changes.

The final step matters because a property update can interact with your account's automation. A successful HTTP response alone cannot establish that the whole CRM workflow behaved as intended.

Scope a maintained CRM feed

Start with the local demo and the free public tender data. Those downloads have their own documented schema; they are not drop-in SAM.gov API responses for this script. Map the relevant source identifiers and fields explicitly.

This is a reference implementation, not a one-click WebTruffle HubSpot connector. A managed delivery needs agreed sources, filters, field ownership, update rules, destination configuration and acceptance checks. Public snapshots and CSV access are already free; the paid work is operating and maintaining the agreed feed.

Frequently asked questions

Does this example create a new deal for every amendment?

No. An observation with the same notice ID targets the existing deal. A different notice ID remains separate; combining related notices into one pursuit requires an additional relationship model.

Will a deadline update reset the sales stage?

The supplied adapter writes pipeline and stage only on creation. Later patches contain changed source properties. Check your HubSpot workflows too, because account automation can react independently to those updates.

Has the importer been tested in HubSpot?

The mapping, rerun and simulated deadline-update behavior have been tested locally. The HTTP adapter has not been verified in a live HubSpot account. Follow the test-account acceptance sequence before deploying it.