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
- Go to DocuSign Admin -> Integrations -> Connect
- Create a Connect configuration with your endpoint URL
- Enable HMAC Security: Manage -> Add Key -> copy the key into
DS_HMAC_KEY - Select events: Envelope Completed, Envelope Declined, Envelope Voided
- 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_idin the envelope at send time- DocuSign Connect webhook -> verify
X-DocuSign-Signature-1 envelope.completed-> update Deal Stage + create Noteenvelope.declined/voided-> revert Stage + create Task- Custom field
docusign_envelope_idon 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.