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.6. Idempotency — never mint a duplicate case
The endpoint is dedup-safe in two independent ways, and both layers are tenant-scoped:
Idempotency-Keyheader — caller-supplied. Replays return the original status + body verbatim, withIdempotent-Replay: true. We usehubspot-{companyId}in the snippet so HubSpot workflow retries (transient errors, action re-runs) are safe.(orgId, crmRecordId)dedup — passcrmRecordId: "{companyId}"and a second call for the same Company returns the existing case (200 OKwithIdempotent-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 havecases:write; mint a new one or extend the existing client's scopes in Compliance Configuration → Public Pull API.400 validation_error—customerEmailis missing or not a valid email; the response body lists each failed field. Check the Company haskyb_contact_emailpopulated before the workflow fires.409 idempotency_key_conflict— the sameIdempotency-Keywas 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.