HubSpot + DocuSign: Why the Native Integration Does Not Show Signature Status on the Deal

HubSpot has a native DocuSign integration available through the Marketplace. Once connected, sending documents for signature is possible directly from HubSpot. The problem: the native integration associates the envelope with a Contact, not a Deal. Sales ops cannot see signature status in the pipeline. There is no automatic deal stage transition when a signature is received. There is no logic for “envelope declined - return deal to previous stage”.

This is not a bug - it is an architectural decision of the HubSpot Marketplace integration. DocuSign Connect (DocuSign’s webhook system) can send events to any receiver, but the native HubSpot connector only listens at the Contact level. A custom integration via DocuSign Connect + HubSpot Engagements API closes this gap.

DocuSign Connect is a push-notification mechanism: when an envelope changes status, DocuSign sends a POST request to a configured URL. The webhook is enabled in DocuSign Admin -> Integrations -> Connect.

What Does Not Work in the Native Integration

The native HubSpot + DocuSign integration can:

  • Send an envelope from HubSpot CRM
  • Record the signed document in the contact’s Timeline

The native integration cannot:

  • Show envelope status on the Deal card
  • Automatically move a Deal to the next stage upon signing
  • Create a task if the envelope is declined or expires
  • Log to the Deal Timeline with the correct activity type

Result: the sales team looks at the pipeline and has no idea whether the contract is signed for open deals. They ask manually or switch to DocuSign.

The Right Architecture

HubSpot Deal: stage -> Contract Sent
  -> Send envelope via DocuSign API
     textCustomField kommo_deal_id = HubSpot Deal ID

DocuSign
  -> Envelope signed (envelope.completed)
  -> POST /your-server/webhooks/docusign
     {status: completed, customFields: [{name: hubspot_deal_id, value: 123}]}

Your server
  -> Verify X-DocuSign-Signature-1
  -> HubSpot Deals API: update deal stage to "Closed Won"
  -> HubSpot Engagements API: create note/attachment in Deal Timeline

Implementation: Sending an Envelope from a HubSpot Deal

import requests, os
import base64, hashlib, hmac

DS_ACCOUNT_ID    = os.environ["DOCUSIGN_ACCOUNT_ID"]
DS_ACCESS_TOKEN  = os.environ["DOCUSIGN_ACCESS_TOKEN"]  # OAuth JWT or regular token
DS_TEMPLATE_ID   = os.environ["DOCUSIGN_TEMPLATE_ID"]  # Contract template ID
DS_HMAC_KEY      = os.environ["DOCUSIGN_HMAC_KEY"]      # Connect HMAC secret

HS_TOKEN         = os.environ["HUBSPOT_PRIVATE_APP_TOKEN"]
HS_BASE          = "https://api.hubapi.com"
HS_HDR           = {"Authorization": f"Bearer {HS_TOKEN}", "Content-Type": "application/json"}

DS_BASE          = f"https://na4.docusign.net/restapi/v2.1/accounts/{DS_ACCOUNT_ID}"
DS_HDR           = {"Authorization": f"Bearer {DS_ACCESS_TOKEN}", "Content-Type": "application/json"}

def send_contract(deal_id: str, signer_email: str, signer_name: str) -> str:
    payload = {
        "templateId": DS_TEMPLATE_ID,
        "templateRoles": [{
            "email":     signer_email,
            "name":      signer_name,
            "roleName":  "Client",
            "tabs": {}
        }],
        "customFields": {
            "textCustomFields": [{
                "name":     "hubspot_deal_id",
                "value":    str(deal_id),
                "required": "false",
                "show":     "false"
            }]
        },
        "status": "sent",
    }
    r = requests.post(f"{DS_BASE}/envelopes", headers=DS_HDR, json=payload)
    r.raise_for_status()
    envelope_id = r.json()["envelopeId"]

    # Save envelope_id to HubSpot Deal custom property
    requests.patch(
        f"{HS_BASE}/crm/v3/objects/deals/{deal_id}",
        headers=HS_HDR,
        json={"properties": {"docusign_envelope_id": envelope_id}},
    )
    return envelope_id

Implementation: DocuSign Connect -> HubSpot

from flask import Flask, request, jsonify

app = Flask(__name__)

def verify_docusign_hmac(raw_body: bytes, signature_header: str) -> bool:
    # DocuSign Connect HMAC: base64(hmac-sha256(body, key))
    computed = base64.b64encode(
        hmac.new(DS_HMAC_KEY.encode(), raw_body, hashlib.sha256).digest()
    ).decode()
    return hmac.compare_digest(computed, signature_header)

@app.route("/webhooks/docusign", methods=["POST"])
def docusign_webhook():
    sig = request.headers.get("X-DocuSign-Signature-1", "")
    if not verify_docusign_hmac(request.data, sig):
        return jsonify({"error": "invalid signature"}), 401

    data   = request.json or {}
    status = data.get("status", "")

    # Extract hubspot_deal_id from customFields
    custom_fields = data.get("customFields", {}).get("textCustomFields", [])
    deal_id = None
    for f in custom_fields:
        if f.get("name") == "hubspot_deal_id":
            deal_id = f.get("value")
            break

    if not deal_id:
        return jsonify({"status": "no_deal_id"}), 200

    if status == "completed":
        handle_signed(deal_id, data)
    elif status in ("declined", "voided"):
        handle_declined(deal_id, status, data)

    return jsonify({"status": "ok"}), 200

def handle_signed(deal_id: str, data: dict):
    # Move deal to "Closed Won"
    requests.patch(
        f"{HS_BASE}/crm/v3/objects/deals/{deal_id}",
        headers=HS_HDR,
        json={"properties": {
            "dealstage":      "closedwon",
            "contract_signed_at": data.get("completedDateTime", ""),
        }},
    )

    # Add Note to Deal Timeline
    requests.post(
        f"{HS_BASE}/crm/v3/objects/notes",
        headers=HS_HDR,
        json={
            "properties": {
                "hs_note_body":      "DocuSign: contract signed by all parties.",
                "hs_timestamp":      str(int(__import__("time").time() * 1000)),
            },
            "associations": [{
                "to": {"id": deal_id},
                "types": [{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 214}]
            }]
        },
    )

def handle_declined(deal_id: str, status: str, data: dict):
    label = "declined by client" if status == "declined" else "voided"
    # Return deal to previous stage and create a task
    requests.patch(
        f"{HS_BASE}/crm/v3/objects/deals/{deal_id}",
        headers=HS_HDR,
        json={"properties": {"dealstage": "presentationscheduled"}},
    )
    requests.post(
        f"{HS_BASE}/crm/v3/objects/tasks",
        headers=HS_HDR,
        json={
            "properties": {
                "hs_task_body":    f"DocuSign envelope {label}. Clarify the reason and resend.",
                "hs_task_status":  "NOT_STARTED",
                "hs_task_type":    "TODO",
                "hs_timestamp":    str(int(__import__("time").time() * 1000) + 86400000),
            },
            "associations": [{
                "to": {"id": deal_id},
                "types": [{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 216}]
            }]
        },
    )

What to Configure in DocuSign

  1. Go to DocuSign Admin -> Integrations -> Connect
  2. Create a Connect configuration with your endpoint URL
  3. Enable HMAC Security: Manage -> Add Key -> copy the key into DS_HMAC_KEY
  4. Select events: Envelope Completed, Envelope Declined, Envelope Voided
  5. Include Document Fields: YES (to receive customFields in the payload)

Real-World Case

A SaaS company with 8 AEs in HubSpot. The native DocuSign integration had been in use for six months. Sales ops spent 2-3 hours per week manually checking envelope statuses and updating deal stages. Clients would sign the contract, but the deal in HubSpot remained in “Contract Sent” until someone updated it by hand.

After implementing the custom integration: the deal moves to Closed Won within a minute of signing. Tasks are created automatically on decline. Sales ops stopped checking DocuSign manually.

Who This Is Relevant For

Companies using DocuSign for contracts and HubSpot as their CRM. Especially when the contracting cycle takes more than 3 days and the pipeline includes stages like “Contract Sent” / “Signed” / “Active”.

A similar anti-pattern is described for HubSpot + Zoom native integration.

Frequently Asked Questions

How does DocuSign API authorization work (JWT vs OAuth)?

For server-to-server scenarios (no user involvement), use JWT Grant with a service account: sign the JWT with your RSA private key and exchange it for an access token with a 1-hour TTL. For one-off tasks, a Personal Access Token from DocuSign Admin works fine. All API calls described in this article work with either authorization method.

Can textCustomFields in DocuSign be visible to signers?

The "show": "false" parameter hides the field from signers. The field is used only for machine-side processing (webhook correlation). Make sure required is also false - otherwise the signer will see an unfilled required field.

What to do if DocuSign sent a webhook but the HubSpot Deal ID was not found?

This can happen if the envelope was created outside the integration (for example, directly in DocuSign). Log all incoming webhooks with the envelope_id. Set up monitoring: if a webhook cannot find a deal_id, send an alert to Slack. This helps identify envelopes that bypass the integration.

Summary

The native HubSpot + DocuSign integration associates envelopes with Contact, not Deal. A custom integration via DocuSign Connect + HubSpot API handles this:

  • textCustomFields.hubspot_deal_id in the envelope at send time
  • DocuSign Connect webhook -> verify X-DocuSign-Signature-1
  • envelope.completed -> update Deal Stage + create Note
  • envelope.declined/voided -> revert Stage + create Task
  • Custom field docusign_envelope_id on the Deal for back-reference

If the native integration is slowing down your closing process - contact Exceltic.dev. We implement custom integrations tailored to your HubSpot stack.

More articles

All →