HubSpot + DocuSign: почему нативная интеграция не показывает статус подписи в сделке

HubSpot имеет нативную интеграцию с DocuSign через Marketplace. После подключения отправка документов на подпись доступна прямо из HubSpot. Проблема: нативная интеграция ассоциирует конверт с Контактом (Contact), а не со Сделкой (Deal). Sales ops не видит статус подписи в pipeline. Нет автоматического перехода сделки на следующий этап при получении подписи. Нет логики “конверт отклонён - вернуть сделку на предыдущий этап”.

Это не баг - это архитектурное решение HubSpot Marketplace интеграции. DocuSign Connect (webhook-система DocuSign) умеет слать события любому получателю, но нативный коннектор HubSpot слушает только на уровне Contact. Кастомная интеграция через DocuSign Connect + HubSpot Engagements API решает этот разрыв.

DocuSign Connect - механизм push-уведомлений: при смене статуса конверта DocuSign отправляет POST-запрос на настроенный URL. Webhook включается в настройках DocuSign Admin -> Integrations -> Connect.

Что не работает в нативной интеграции

Нативная HubSpot + DocuSign интеграция умеет:

  • Отправлять конверт из HubSpot CRM
  • Записывать подписанный документ в Timeline контакта

Нативная интеграция НЕ умеет:

  • Показывать статус конверта в карточке Сделки
  • Автоматически переводить Deal в следующий stage при подписании
  • Создавать задачу если конверт отклонён или истёк срок
  • Логировать в Deal Timeline с правильным типом активности

Итог: sales team смотрит на воронку и не знает, подписан ли контракт по открытым сделкам. Спрашивают вручную или переключаются в DocuSign.

Правильная архитектура

HubSpot Deal: stage -> Contract Sent
  -> Отправить конверт через DocuSign API
     textCustomField kommo_deal_id = HubSpot Deal ID

DocuSign
  -> Конверт подписан (envelope.completed)
  -> POST /your-server/webhooks/docusign
     {status: completed, customFields: [{name: hubspot_deal_id, value: 123}]}

Ваш сервер
  -> Верифицировать X-DocuSign-Signature-1
  -> HubSpot Deals API: обновить deal stage на "Closed Won"
  -> HubSpot Engagements API: создать note/attachment в Deal Timeline

Реализация: отправка конверта из HubSpot сделки

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 или обычный token
DS_TEMPLATE_ID   = os.environ["DOCUSIGN_TEMPLATE_ID"]  # 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"]

    # Сохранить envelope_id в 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

Реализация: 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", "")

    # Извлечь hubspot_deal_id из 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):
    # Перевести сделку в "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", ""),
        }},
    )

    # Добавить Note в Deal Timeline
    requests.post(
        f"{HS_BASE}/crm/v3/objects/notes",
        headers=HS_HDR,
        json={
            "properties": {
                "hs_note_body":      "DocuSign: контракт подписан всеми сторонами.",
                "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 = "отклонён клиентом" if status == "declined" else "аннулирован"
    # Вернуть сделку на предыдущий этап и создать задачу
    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 конверт {label}. Выяснить причину и переотправить.",
                "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}]
            }]
        },
    )

Что нужно настроить в DocuSign

  1. Перейти в DocuSign Admin -> Integrations -> Connect
  2. Создать Connect configuration с URL вашего endpoint
  3. Включить HMAC Security: Manage -> Add Key -> скопировать в DS_HMAC_KEY
  4. Выбрать события: Envelope Completed, Envelope Declined, Envelope Voided
  5. Include Document Fields: YES (чтобы получить customFields в payload)

Реальный кейс

SaaS-компания, 8 AE в HubSpot. Нативная DocuSign интеграция использовалась полгода. Sales ops тратил 2-3 часа еженедельно на ручную проверку статусов конвертов и обновление deal stages. Клиенты подписывали контракт, но сделка в HubSpot оставалась в “Contract Sent” до ручного обновления.

После внедрения кастомной интеграции: сделка переходит в Closed Won в течение минуты после подписания. Tasks создаются автоматически при отклонении. Sales ops перестал вручную проверять DocuSign.

Для кого актуально

Компании, использующие DocuSign для контрактов и HubSpot как CRM. Особенно если цикл контрактования занимает >3 дней и воронка содержит стадии “Contract Sent” / “Signed” / “Active”.

Похожий антипаттерн описан для HubSpot + Zoom нативной интеграции.

Часто задаваемые вопросы

Как работает авторизация DocuSign API (JWT vs OAuth)?

Для server-to-server (без участия пользователя) используйте JWT Grant с сервисным аккаунтом: подписываете JWT своим RSA private key, меняете на access token с TTL 1 час. Для разовых задач подходит Personal Access Token из DocuSign Admin. Все описанные в статье вызовы API работают с любым методом авторизации.

Могут ли textCustomFields в DocuSign быть видны подписантам?

Параметр "show": "false" скрывает поле от подписантов. Поле используется только для машинной обработки (webhook correlation). Убедитесь что required тоже false - иначе подписант увидит незаполненное обязательное поле.

Что делать если DocuSign отправил webhook но HubSpot Deal ID не найден?

Такое возможно если конверт создан не через интеграцию (например, напрямую в DocuSign). Логируйте все входящие webhooks с envelope_id. Настройте мониторинг: если webhook не нашёл deal_id - alert в Slack. Это поможет выявить конверты вне интеграции.

Итог

Нативная HubSpot + DocuSign интеграция ассоциирует конверты с Contact, не с Deal. Кастомная интеграция через DocuSign Connect + HubSpot API:

  • textCustomFields.hubspot_deal_id в конверте при отправке
  • DocuSign Connect webhook -> верификация X-DocuSign-Signature-1
  • envelope.completed -> обновить Deal Stage + создать Note
  • envelope.declined/voided -> вернуть Stage + создать Task
  • Кастомное поле docusign_envelope_id в Deal для обратной ссылки

Если нативная интеграция тормозит ваш закрывающий процесс - обратитесь в Exceltic.dev. Реализуем кастомную связку под ваш стек HubSpot.

Ещё статьи

Все →