HubSpot integration guide

One-click KYB Onboarding from HubSpot

Drop an Initiate KYB Onboarding workflow onto your HubSpot Company object. One property change creates a case in First Mile Labs, sends the customer their tenant-branded magic-link invite, and starts streaming progress back into HubSpot via outbound webhooks — all without leaving the Company record.

1. Overview

The recipe has four pieces: an OAuth2 client credential minted in Compliance Configuration → Public Pull API (with the cases:write scope), two custom properties on the HubSpot Company object (KYB contact email + the returned FML case id), a HubSpot Workflow with a Custom Code action that calls POST /v1/cases, and a follow-up "Copy property value" action that writes the FML case id back onto the Company. Outbound webhooks then push status changes back to the same Company via the crmRecordId you sent on the create call.

Prerequisites: HubSpot Operations Hub Professional or higher (Custom Code actions are gated on that tier), an FML tenant with a Public API client that has cases:write, and two custom Company properties: kyb_contact_email (single-line text), kyb_contact_name(single-line text), and fml_case_id (single-line text, used for the round-trip).

2. Client credentials

In First Mile Labs go to Compliance Configuration → Public Pull API → New clientand tick the cases:write scope. Copy the client_id and client_secret — the secret is shown once.

In HubSpot, open the Workflow you'll build in section 4 and add both values to the Custom Code action's Secrets panel as FML_CLIENT_ID and FML_CLIENT_SECRET. HubSpot encrypts these at rest and exposes them to the action runtime via process.env.

3. Company properties

Settings → Properties → Company properties → Create property. Add three single-line text properties:

  • kyb_contact_email — the email address the customer-portal magic link is sent to.
  • kyb_contact_name — display name shown in the invite email salutation.
  • fml_case_id — populated automatically after the workflow fires; used to route inbound webhooks.

If you already have equivalent fields (e.g. primary_contact_email) you can map them in step 4 instead of creating new ones.

4. Workflow + custom code action

Automations → Workflows → Create workflow → Company-based. Pick an enrolment trigger that fits your sales process — most teams use Lifecycle stage becomes Opportunity or a custom "Ready for KYB" checkbox is true. Then add a single Custom Code action.

Pass the Company's Record ID, Name, Country, and the two custom KYB properties as input fields named exactly companyId, name, country, email, contact. Then paste:

// HubSpot Workflow → Custom Code action (Node.js 18)
// Trigger: "Lifecycle stage becomes Opportunity" on the Company object
// (or any property change that signals 'time to start KYB').
//
// Secrets to add in the action's "Secrets" panel:
//   FML_CLIENT_ID, FML_CLIENT_SECRET
//
// Property inputs to pass into the action:
//   companyId  → Company → Record ID (hs_object_id)
//   name       → Company → Name
//   country    → Company → Country/Region
//   email      → Company → KYB Contact Email (custom prop)
//   contact    → Company → KYB Contact Name  (custom prop)

exports.main = async (event, callback) => {
  const { companyId, name, country, email, contact } = event.inputFields;

  // 1. Mint a short-lived OAuth2 bearer token.
  const tokenRes = await fetch('https://www.firstmilelabs.com/v1/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      grant_type:    'client_credentials',
      client_id:     process.env.FML_CLIENT_ID,
      client_secret: process.env.FML_CLIENT_SECRET,
    }),
  });
  const { access_token } = await tokenRes.json();

  // 2. Create the case. Idempotent in two ways: the Idempotency-Key
  //    header (safe on workflow retries) AND the (orgId, crmRecordId)
  //    dedup (safe if the workflow ever re-fires for the same Company).
  const res = await fetch('https://www.firstmilelabs.com/v1/cases', {
    method: 'POST',
    headers: {
      'Authorization':   `Bearer ${access_token}`,
      'Content-Type':    'application/json',
      'Idempotency-Key': `hubspot-${companyId}`,
    },
    body: JSON.stringify({
      customerEmail: email,
      customerName:  contact,
      companyName:   name,
      country:       country,
      crmRecordId:   String(companyId),
      crmSource:     'hubspot',
      sendInvite:    true,
    }),
  });
  const body = await res.json();

  if (res.status !== 200 && res.status !== 201) {
    callback({ outputFields: { ok: false, error: body.error || 'unknown' } });
    return;
  }

  // 3. Echo the FML caseId back onto the Company record so subsequent
  //    case.decision.made webhooks (which carry crmRecordId) can land
  //    straight on this row.
  callback({
    outputFields: {
      ok:                true,
      caseId:            body.caseId,
      customerPortalUrl: body.customerPortalUrl,
      inviteSentAt:      body.inviteSentAt,
    },
  });
};

Chain a second action — Copy property value — to stamp the returned case id onto the Company:

// Follow the Custom Code action with a "Copy property value" action:
//   Source : Custom Code output → caseId
//   Target : Company → fml_case_id  (single-line text, custom property)
//
// That single field is what every future outbound webhook's crmRecordId
// will route against.

5. One-click button (optional)

Workflows fire on property changes, which suits most teams. If you prefer an explicit button on the Company record, add a HubSpot Custom Card (CRM development) or a UI Extension that posts to a private app endpoint which in turn runs the same fetch sequence as the Custom Code action above. The payload, headers, and idempotency contract are identical — only the trigger changes from "property update" to "rep clicked a button".

6. Idempotency — never mint a duplicate case

The endpoint is dedup-safe in two independent ways, and both layers are tenant-scoped:

  • Idempotency-Key header — caller-supplied. Replays return the original status + body verbatim, with Idempotent-Replay: true. We use hubspot-{companyId} in the snippet so HubSpot workflow retries (transient errors, action re-runs) are safe.
  • (orgId, crmRecordId) dedup — pass crmRecordId: "{companyId}" and a second call for the same Company returns the existing case (200 OK with Idempotent-Replay: crm-record-id) instead of creating a duplicate.
HTTP/1.1 201 Created

{
  "caseId":            "8a0e8d9c-7b1a-4f2e-9c3b-2d1e8f7a4b6c",
  "customerEmail":     "[email protected]",
  "customerName":      "Jane Doe",
  "companyName":       "Acme Holdings Ltd",
  "country":           "United Kingdom",
  "entityType":        null,
  "crmRecordId":       "12345678901",
  "crmSource":         "hubspot",
  "source":            "api",
  "status":            "in-progress",
  "inviteSentAt":      "2026-05-15T09:14:11.421Z",
  "customerPortalUrl": "https://app.firstmilelabs.com/",
  "analystUrl":        "https://cases.firstmilelabs.com/?case=8a0e8d9c-…"
}
HTTP/1.1 200 OK
Idempotent-Replay: crm-record-id

{
  "caseId":      "8a0e8d9c-…",   // existing case for this HubSpot Company
  "crmRecordId": "12345678901",
  "source":      "api",
  ...
}

7. Webhook round-trip

Every outbound webhook event for this case (case.created, case.submitted, case.decision.made, …) carries the crmRecordId you supplied on the create call. Wire your existing webhook receiver — or our Workato recipe — to update the Company record using that id, and you have a closed-loop pipeline from MQL to onboarded without anyone ever touching the FML analyst console.

Source badge in the analyst console: cases minted via this flow show an orange API · HubSpot badge in the dashboard so analysts immediately know the case originated from sales, not the upload portal.

8. Troubleshooting

  • 401 invalid_client — the client secret in HubSpot's Secrets panel is stale; re-paste from FML.
  • 403 insufficient_scope — the client doesn't have cases:write; mint a new one or extend the existing client's scopes in Compliance Configuration → Public Pull API.
  • 400 validation_errorcustomerEmail is missing or not a valid email; the response body lists each failed field. Check the Company has kyb_contact_email populated before the workflow fires.
  • 409 idempotency_key_conflict — the same Idempotency-Key was previously used on a different endpoint; pick a fresh key.
  • HubSpot Custom Code action timeout — actions are capped at 20s. The fetch sequence above runs comfortably inside that, but if you add extra logic keep an eye on action logs.

For the full request/response contract see the Public API guide or the machine-readable OpenAPI spec.