---
title: "Send documents from a HubSpot workflow"
url: "https://docs.signnow.com/docs/hubspot-workflow-send"
type: "page"
section: "Integrations"
slug: "hubspot-workflow-send"
---

# Send documents from a HubSpot workflow

# Send documents from a HubSpot workflow

Create a SignNow document group from a template and send the signing invite when a HubSpot record meets your criteria.

## Use case overview

The SignNow integration for HubSpot sends documents from a record, one at a time. This use case moves the send into a workflow: enrol a contact, and the documents are created from a SignNow document group template and the invite goes out without anyone opening the record.

It runs as a HubSpot **Custom code** workflow action. The action reads the enrolled record, authenticates to SignNow, creates the document group, and sends the invite.

**No server required.** HubSpot hosts the code itself on Node.js 20.x, so there is nothing to deploy, host, or keep online. The whole use case is a script pasted into a workflow action and four values stored as secrets.

```mermaid
flowchart TD
    A[Record enrolls in the HubSpot workflow] --> B[Custom code action reads the record's email]
    B --> C[SignNow authenticates the integration account]
    C --> D[Document group created from the document group template]
    D --> E[Signing invite sent to the recipients]
    E --> F[Action returns the document group ID and status]
```

> [!IMPORTANT]
> Documents sent this way are created through the SignNow API rather than through the integration. They do not appear in the SignNow card on the record, do not attach back to the record when signed, and do not appear on the activity feed. Track them in SignNow, or add the webhook setup described in [Track signature status on the HubSpot activity feed](/docs/hubspot-activity-feed-sync), matching events on the document group ID this action returns.

## Prerequisites

- HubSpot Data Hub Professional or Enterprise. Custom code actions are available on those tiers.
- Permission to create service keys and workflows in the HubSpot account.
- A SignNow API application, and the credentials of the account that owns it.
- The SignNow app for HubSpot, installed as described in [Installation](/docs/hubspot-installation).
- A document group template with fillable fields, created as described in [Create a template](/docs/hubspot-admin-tools#create-a-template). Sign in to **Admin Tools** as the same account the workflow action authenticates as.

Templates created from HubSpot are stored as document group templates. A group can hold one document or several, and the action sends the whole group either way.

## Configuration guide

### Step 1. Create the SignNow API application

The action authenticates with a SignNow API application and the credentials of the account that owns it.

Retrieve the Basic authorization token from the SignNow API dashboard: **Apps and Keys** > select or create your application > **OAuth 2.0** tab. The token encodes the application's client ID and secret, so storing it means the code needs neither value separately.

![b1-01-basic-authorization-token.png](/reference-assets/images/SignNow_Hubspot/activity_feed_sync/b1-01-basic-authorization-token.png)

Take the account's email and password as well. All three values go into the workflow secrets in [step 5](#step-5-add-the-custom-code-action).

> [!IMPORTANT]
> Use the credentials of the account that **owns the API application**. The password grant works only for the application owner: any other account returns `Access denied` (code `11005001`), even when the same credentials sign in to the SignNow web application.

### Step 2. Create a HubSpot service key

The action reads the enrolled record through the HubSpot API, which needs its own credential.

1. In HubSpot, go to **Development** > **Keys** > **Service Keys** and click **Create service key**.

![a1-01-service-keys-empty-state.png](/reference-assets/images/SignNow_Hubspot/workflow_send/a1-01-service-keys-empty-state.png)

2. Name it something that identifies this integration, for example `SignNow workflow send`. The name appears in HubSpot logs, and an account can hold several SignNow-related credentials.
3. Click **Add new scope**, search for `contacts`, and select `crm.objects.contacts.read`. Use `crm.objects.deals.read` instead if the workflow enrolls deals.

![a1-02-service-key-scope-contacts-read.png](/reference-assets/images/SignNow_Hubspot/workflow_send/a1-02-service-key-scope-contacts-read.png)

4. Click **Update**, then **Save**.
5. On the key's page, click **Copy** to take the service key. You paste it into a workflow secret in [step 5](#step-5-add-the-custom-code-action).

![a1-03-service-key-detail.png](/reference-assets/images/SignNow_Hubspot/workflow_send/a1-03-service-key-detail.png)

The action only reads a record, so a read scope is enough.

> [!NOTE]
> A legacy private app token works here too, created the same way: name, scope, copy the token. If your account already has one with `crm.objects.contacts.read`, its token goes in the same secret and nothing else changes. HubSpot keeps legacy apps available in sandbox and test accounts, and recommends service keys or project-based apps for production accounts.

### Step 3. Copy the document group template ID

Open a HubSpot record and, on the SignNow card, click **Go to Admin Tools** and select **Templates**. Copy the ID of the document group you want to send from the **Template ID** column. It is a 40-character hexadecimal string.

![b2-01-document-group-templates-id.png](/reference-assets/images/SignNow_Hubspot/workflow_send/b2-01-document-group-templates-id.png)

### Step 4. Create the workflow

1. Go to **Automation** > **Workflows**, click **Create workflow**, and choose **From scratch**.

![a2-01-create-workflow-from-scratch.png](/reference-assets/images/SignNow_Hubspot/workflow_send/a2-01-create-workflow-from-scratch.png)

2. On **Choose a trigger to start this workflow**, select **Trigger manually** and click **Next**. This lets you enrol one record at a time while you build. Once the send works, come back and replace it with **Met filter criteria** or **On a schedule**.

![a2-02-choose-trigger-manually.png](/reference-assets/images/SignNow_Hubspot/workflow_send/a2-02-choose-trigger-manually.png)

3. On **Choose a type of record that can enroll**, select **Contact**, or **Deal** to match the object type set in the code. Click **Save and continue**.

![a2-03-choose-record-type-contact.png](/reference-assets/images/SignNow_Hubspot/workflow_send/a2-03-choose-record-type-contact.png)

Name the workflow with the pencil icon at the top, whenever suits. One workflow sends one document group template, so naming it for the documents it sends — `SignNow – send Employment Agreement`, for example — keeps them distinguishable later.

### Step 5. Add the custom code action

Saving the trigger opens the **Choose an action** panel, with the categories collapsed. Expand **Data ops**, choose **Custom code**, and select **Node.js 20.x** as the language.

![a2-04-data-ops-custom-code.png](/reference-assets/images/SignNow_Hubspot/workflow_send/a2-04-data-ops-custom-code.png)

**Secrets.** Click **Add secret** for each of the four values below, then select each one in this action. A secret has to be selected per action, not only created.

| Secret | Value |
| --- | --- |
| `SIGNNOW_SEND_HS_TOKEN` | the HubSpot service key from [step 2](#step-2-create-a-hubspot-service-key) |
| `SIGNNOW_BASIC_TOKEN` | the Basic authorization token from [step 1](#step-1-create-the-signnow-api-application) |
| `SIGNNOW_USERNAME` | the SignNow account's email |
| `SIGNNOW_PASSWORD` | that account's password |

**Property to include in code.** Add one property, `record_id`, mapped to **Record ID**. The name must match the code exactly; a mismatch produces an undefined value rather than an error.

![a3-01-custom-code-secrets-and-input.png](/reference-assets/images/SignNow_Hubspot/workflow_send/a3-01-custom-code-secrets-and-input.png)

This panel accepts HubSpot properties, so the document group template ID goes in the code, in [step 6](#step-6-configure-the-code).

**Data outputs.** Add three outputs, all of type String: `signnow_send_status`, `signnow_document_id`, and `signnow_error`.

![a3-02-data-outputs.png](/reference-assets/images/SignNow_Hubspot/workflow_send/a3-02-data-outputs.png)

Replace the sample code in the editor with the script below, then set the values at the top of it as described in [step 6](#step-6-configure-the-code).

```javascript
// ===========================================================================
// CONFIGURATION
// ===========================================================================

// The HubSpot object the workflow enrolls: 'contacts' or 'deals'.
// Must match the workflow's record type and the scope on the service key.
const HUBSPOT_OBJECT_TYPE = 'contacts';

// The HubSpot property holding the default signer's email.
const DEFAULT_EMAIL_PROPERTY = 'email';

// The document group template this workflow sends. 40-character hex, from
// Admin Tools > Templates, in the Template ID column.
const DOCUMENT_GROUP_TEMPLATE_ID = 'paste-your-document-group-template-id-here';

// Multi-role document groups only: SignNow role name -> HubSpot property
// that signer's email. Leave {} for a single signer.
const ROLE_EMAIL_MAP = {};

// The invite email recipients receive.
const INVITE_SUBJECT = 'Please sign the attached document';
const INVITE_MESSAGE = 'Please review and sign at your earliest convenience.';

// null leaves the SignNow account defaults in place.
const INVITE_EXPIRATION_DAYS = null;   // e.g. 30
const INVITE_REMINDER_DAYS = null;     // e.g. 3

// ===========================================================================

const SIGNNOW_API = 'https://api.signnow.com';
const HUBSPOT_API = 'https://api.hubapi.com';

async function httpJson(url, options, label) {
  let res;
  try {
    res = await fetch(url, options);
  } catch (err) {
    throw new Error(`${label}: network error - ${err.message}`);
  }

  const text = await res.text();
  let body = null;
  if (text) {
    try { body = JSON.parse(text); } catch { body = text; }
  }

  if (!res.ok) {
    let detail = '';
    if (body && Array.isArray(body.errors)) {
      detail = body.errors.map(e => `${e.code ?? ''} ${e.message ?? ''}`.trim()).join('; ');
    } else if (body && body.error) {
      detail = typeof body.error === 'string' ? body.error : JSON.stringify(body.error);
    } else if (body && body.message) {
      detail = body.message;
    } else if (typeof body === 'string') {
      detail = body.slice(0, 300);
    }
    throw new Error(`${label}: HTTP ${res.status}${detail ? ` - ${detail}` : ''}`);
  }

  return body;
}

async function getSignNowToken() {
  const { SIGNNOW_BASIC_TOKEN, SIGNNOW_USERNAME, SIGNNOW_PASSWORD } = process.env;

  for (const [name, value] of Object.entries({
    SIGNNOW_BASIC_TOKEN, SIGNNOW_USERNAME, SIGNNOW_PASSWORD,
  })) {
    if (!value) throw new Error(`Missing secret: ${name}`);
  }

  const form = new URLSearchParams({
    grant_type: 'password',
    username: SIGNNOW_USERNAME,
    password: SIGNNOW_PASSWORD,
    scope: '*',
  });

  const body = await httpJson(`${SIGNNOW_API}/oauth2/token`, {
    method: 'POST',
    headers: {
      Authorization: `Basic ${SIGNNOW_BASIC_TOKEN}`,
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: form.toString(),
  }, 'SignNow auth failed');

  if (!body || !body.access_token) {
    throw new Error('SignNow auth failed: no access_token in response');
  }
  return body.access_token;
}

function signNowHeaders(token) {
  return {
    Authorization: `Bearer ${token}`,
    'Content-Type': 'application/json',
  };
}

async function getHubSpotRecord(recordId, properties) {
  const token = process.env.SIGNNOW_SEND_HS_TOKEN;
  if (!token) throw new Error('Missing secret: SIGNNOW_SEND_HS_TOKEN');

  const qs = new URLSearchParams({ properties: properties.join(',') });
  const url = `${HUBSPOT_API}/crm/v3/objects/${HUBSPOT_OBJECT_TYPE}/${recordId}?${qs}`;

  const body = await httpJson(url, {
    method: 'GET',
    headers: { Authorization: `Bearer ${token}` },
  }, 'HubSpot record fetch failed');

  return body.properties || {};
}

function buildRoleResolver(recordProperties) {
  const fallback = recordProperties[DEFAULT_EMAIL_PROPERTY];

  return function resolve(roleName) {
    const property = ROLE_EMAIL_MAP[roleName];
    const email = property ? recordProperties[property] : fallback;

    if (!email) {
      throw new Error(
        property
          ? `Role "${roleName}" maps to HubSpot property "${property}", which is empty on this record`
          : `Record has no "${DEFAULT_EMAIL_PROPERTY}" property and no ROLE_EMAIL_MAP override for role "${roleName}"`
      );
    }
    return email;
  };
}

async function createGroupFromTemplate(token, groupTemplateId, groupName) {
  const body = await httpJson(
    `${SIGNNOW_API}/v2/document-group-templates/${groupTemplateId}/document-group`,
    {
      method: 'POST',
      headers: signNowHeaders(token),
      body: JSON.stringify({
        group_name: groupName,
        client_timestamp: String(Math.floor(Date.now() / 1000)),
      }),
    },
    'Create document group failed'
  );

  // The response nests under `data`, and the group ID is data.unique_id.
  const data = (body && body.data) || body || {};
  if (!data.unique_id) {
    throw new Error('Create document group failed: no group ID returned');
  }

  // Documents in the new group get new IDs. Build the invite from these.
  return {
    groupId: data.unique_id,
    documents: Array.isArray(data.documents) ? data.documents : [],
  };
}

async function sendGroupInvite(token, groupId, documents, resolveEmail) {
  // Each step carries invite_actions (role bound to document) and
  // invite_emails (the mail each recipient receives).
  const stepActions = new Map();
  const stepEmails = new Map();

  for (const doc of documents) {
    if (!doc.id) continue;

    const roles = (doc.roles || []).length ? doc.roles : [''];

    for (const role of roles) {
      const roleName = typeof role === 'string' ? role : (role.name || '');
      const order = typeof role === 'string'
        ? 1
        : (parseInt(role.signing_order, 10) || 1);

      const email = resolveEmail(roleName);

      if (!stepActions.has(order)) stepActions.set(order, []);
      const action = { action: 'sign', document_id: doc.id, email };
      if (roleName) action.role_name = roleName;
      stepActions.get(order).push(action);

      // One email per recipient per step, however many documents they sign.
      if (!stepEmails.has(order)) stepEmails.set(order, new Map());
      if (!stepEmails.get(order).has(email)) {
        const mail = { email, subject: INVITE_SUBJECT, message: INVITE_MESSAGE };
        if (INVITE_EXPIRATION_DAYS) mail.expiration_days = INVITE_EXPIRATION_DAYS;
        if (INVITE_REMINDER_DAYS) mail.reminder = INVITE_REMINDER_DAYS;
        stepEmails.get(order).set(email, mail);
      }
    }
  }

  if (!stepActions.size) {
    throw new Error('Send invite failed: the document group has nothing to sign');
  }

  const invite_steps = [...stepActions.keys()]
    .sort((a, b) => a - b)
    .map(order => ({
      order,
      invite_actions: stepActions.get(order),
      invite_emails: [...stepEmails.get(order).values()],
    }));

  await httpJson(`${SIGNNOW_API}/documentgroup/${groupId}/groupinvite`, {
    method: 'POST',
    headers: signNowHeaders(token),
    body: JSON.stringify({
      invite_steps,
      client_timestamp: Math.floor(Date.now() / 1000),
    }),
  }, 'Send invite failed');

  return groupId;
}

exports.main = async (event, callback) => {
  const outputFields = {
    signnow_send_status: 'ERROR',
    signnow_document_id: '',
    signnow_error: '',
  };

  try {
    const recordId = event.inputFields.record_id;
    if (!recordId) throw new Error('Input field record_id is empty');

    if (!DOCUMENT_GROUP_TEMPLATE_ID ||
        DOCUMENT_GROUP_TEMPLATE_ID === 'paste-your-document-group-template-id-here') {
      throw new Error('Set DOCUMENT_GROUP_TEMPLATE_ID in the configuration block');
    }

    // Document flow IDs are short integers and cannot be used here.
    if (/^\d{1,8}$/.test(String(DOCUMENT_GROUP_TEMPLATE_ID).trim())) {
      throw new Error(
        `DOCUMENT_GROUP_TEMPLATE_ID "${DOCUMENT_GROUP_TEMPLATE_ID}" is a document flow ID. ` +
        'Use the ID from the Template ID column in Admin Tools > Templates.'
      );
    }

    const properties = [...new Set([
      DEFAULT_EMAIL_PROPERTY,
      ...Object.values(ROLE_EMAIL_MAP),
    ])];

    const recordProperties = await getHubSpotRecord(recordId, properties);
    const resolveEmail = buildRoleResolver(recordProperties);
    const token = await getSignNowToken();

    const groupName = `HubSpot ${HUBSPOT_OBJECT_TYPE} ${recordId}`;
    const { groupId, documents } =
      await createGroupFromTemplate(token, DOCUMENT_GROUP_TEMPLATE_ID, groupName);
    const resultId = await sendGroupInvite(token, groupId, documents, resolveEmail);

    outputFields.signnow_send_status = 'SENT';
    outputFields.signnow_document_id = String(resultId);
  } catch (err) {
    outputFields.signnow_error =
      String(err && err.message ? err.message : err).slice(0, 1000);
    console.error('SignNow send failed:', outputFields.signnow_error);
  }

  callback({ outputFields });
};
```

### Step 6. Configure the code

Everything you normally edit is in the configuration block at the top of the script.

- **`HUBSPOT_OBJECT_TYPE`** — `'contacts'` or `'deals'`. It must match the workflow's record type and the scope on the service key.
- **`DOCUMENT_GROUP_TEMPLATE_ID`** — the ID from [step 3](#step-3-copy-the-document-group-template-id).
- **`ROLE_EMAIL_MAP`** — leave as `{}` for a single signer, and every role resolves to the record's email property.
- **`INVITE_SUBJECT`** and **`INVITE_MESSAGE`** — the email recipients receive.
- **`INVITE_EXPIRATION_DAYS`** and **`INVITE_REMINDER_DAYS`** — `null` leaves the SignNow account defaults in place.

For a document group with several signer roles, map each role name to the HubSpot property holding that signer's email:

```javascript
const ROLE_EMAIL_MAP = {
  'Contractor':    'email',
  'Subcontractor': 'hubspot_owner_email',
};
```

Role names must match the document group template exactly, including case. A key that does not match a role falls back to the record's email property, so both signers would receive the same invite.

Each signer's email comes from a property on the enrolled record. Signing order comes from the document group template, and the action passes it through as one invite step per order.

### Step 7. Test

Open the action's **Test action** section, select a record that has an email address you control, and click **Test**. This runs the code without turning the workflow on, and the invite it sends is real.

![a3-03-test-action-result.png](/reference-assets/images/SignNow_Hubspot/workflow_send/a3-03-test-action-result.png)

`signnow_send_status` reads `SENT` when SignNow accepts the invite, and `signnow_document_id` holds the new document group ID. Check that the invite arrives before treating the test as passed.

### Step 8. Turn the workflow on

Click **Review and turn on**. A workflow that is off enrols nothing, so the action runs only from the **Test action** panel until you do.

With the manual trigger in place, enrol a record from the **Workflows** section of its right-hand sidebar. To send automatically, reopen the trigger and swap **Trigger manually** for **Met filter criteria** or **On a schedule**.

## Result

The documents reach the signers without anyone opening the record. Whatever enrols a record — a deal stage, a form submission, a property change — the send happens as part of the workflow.

## Troubleshooting

**Missing secret: NAME.** The secret exists in the account but is not selected in this action, or the name in the code differs from the name in the panel.

**SignNow auth failed: `Access denied`, code `11005001`.** The account in `SIGNNOW_USERNAME` does not own the API application the Basic token belongs to. Use the application owner's credentials.

**SignNow auth failed: `Invalid credentials`, code `826`.** The email or password is wrong, or the password changed without the secret being updated. An account that signs in through Google, Facebook, or Microsoft needs a password set before the password grant works.

**HubSpot record fetch failed: HTTP 403.** The service key is missing the read scope for the object being enrolled. Check the scope on the key, and that the secret holds the key you granted it on.

**Record has no email property.** The enrolled record has no email address, or a document group with several roles needs a `ROLE_EMAIL_MAP` entry for that role.

**Create document group failed.** Check the ID came from the **Template ID** column on **Admin Tools** > **Templates**, and is 40 characters. A short integer is a document flow ID.

**The invite reached an unexpected address.** The action ran against a different record than intended. The document group name contains the record ID the action received, which identifies which record enrolled.

## API calls this use case makes

| Call | Purpose |
| --- | --- |
| [`POST /oauth2/token`](/docs/oauth2/operations/post-oauth2-token) | Password grant. Returns a token valid for 30 days. |
| [`POST /v2/document-group-templates/{unique_id}/document-group`](/docs/document-group-template/operations/post-v2-document-group-templates-unique-id-document-group) | Creates the document group from the template. |
| [`POST /documentgroup/{document_group_id}/groupinvite`](/docs/doc-group-field-invite/operations/invite-to-sign-document-group) | Sends the signing invite. |

The documents inside a new group get new IDs, so the invite is built from the IDs the create call returns. A group invite carries two arrays per step: `invite_actions`, which binds each role to a document, and `invite_emails`, which controls the mail each recipient receives.


---
*Full page: https://docs.signnow.com/docs/hubspot-workflow-send*
