1. Overview
The recipe has four pieces: an OAuth2 client credential minted in Compliance Configuration → Public Pull API (with the new cases:write scope), a Salesforce Named Credential that holds it, a tiny Apex class that calls POST /v1/cases, and a Lightning quick-action button on the Account page layout. Outbound webhooks then push status changes back to the same Account via the crmRecordId you sent on the create call.
Prerequisites: Salesforce admin access, an FML tenant with a Public API client that has cases:write, and a custom field on Account for the FML case id (FML_Case_Id__c) so the round-trip can target the right record.
2. Named credential
Setup → Named Credentials → External Credential. Authentication Protocol: OAuth 2.0 — Client Credentials. Token endpoint: https://www.firstmilelabs.com/v1/oauth/token. Save the client_id / client_secret as Principal Parameters. Then create a Named Credential FirstMileLabs_PublicAPI bound to the external credential, base URL https://www.firstmilelabs.com.
3. Apex helper
One @AuraEnabled method that the quick action invokes. Salesforce manages the token transparently — the callout: URL prefix is all you need.
// IntegrationProcedure / Apex helper for the "Initiate KYB Onboarding"
// quick-action button on the Account page layout.
//
// One named-credential 'FirstMileLabs_PublicAPI' holds the OAuth2
// client-credentials grant config — Salesforce mints + caches the bearer
// token automatically.
public class FirstMileLabsKyb {
public class Result {
@AuraEnabled public String caseId;
@AuraEnabled public String customerPortalUrl;
@AuraEnabled public String inviteSentAt;
@AuraEnabled public String error;
}
@AuraEnabled
public static Result initiateOnboarding(Id accountId) {
Account a = [
SELECT Id, Name, BillingCountry, KYB_Contact_Email__c, KYB_Contact_Name__c
FROM Account WHERE Id = :accountId
];
Map<String, Object> body = new Map<String, Object>{
'customerEmail' => a.KYB_Contact_Email__c,
'customerName' => a.KYB_Contact_Name__c,
'companyName' => a.Name,
'country' => a.BillingCountry,
'crmRecordId' => String.valueOf(a.Id),
'crmSource' => 'salesforce',
'sendInvite' => true
};
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:FirstMileLabs_PublicAPI/v1/cases');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
// Idempotent retries: same Account never mints a duplicate case.
req.setHeader('Idempotency-Key', 'sfdc-' + accountId);
req.setBody(JSON.serialize(body));
HttpResponse res = new Http().send(req);
Result out = new Result();
Map<String, Object> j = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
if (res.getStatusCode() == 201 || res.getStatusCode() == 200) {
out.caseId = (String) j.get('caseId');
out.customerPortalUrl = (String) j.get('customerPortalUrl');
out.inviteSentAt = (String) j.get('inviteSentAt');
// Echo the FML caseId back onto the Account so future webhooks land
// straight on this record.
update new Account(Id = accountId, FML_Case_Id__c = out.caseId);
} else {
out.error = (String) j.get('error');
}
return out;
}
}On a successful create the response includes caseId; we stamp it onto the Account so subsequent case.decision.made webhooks (which echo back crmRecordId: "0015g00000abc123") can be routed to this Account record without a lookup.
5. No-code alternative (Salesforce Flow)
Prefer click-not-code? Build a Screen Flow with a single HTTP Callout action against the same named credential. The body template is identical:
POST https://www.firstmilelabs.com/v1/cases
Authorization: Bearer {{ACCESS_TOKEN}}
Idempotency-Key: sfdc-{{Account.Id}}
Content-Type: application/json
{
"customerEmail": "{{Account.KYB_Contact_Email__c}}",
"customerName": "{{Account.KYB_Contact_Name__c}}",
"companyName": "{{Account.Name}}",
"country": "{{Account.BillingCountry}}",
"crmRecordId": "{{Account.Id}}",
"crmSource": "salesforce",
"sendInvite": true
}Bind the action to an Account button via {!recordId} and you've shipped the same one-click experience without writing Apex.
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 usesfdc-{Account.Id}in the Apex sample so retries from a flaky network are safe.(orgId, crmRecordId)dedup — passcrmRecordId: "{Account.Id}"and a second call for the same Account 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": "0015g00000abc123",
"crmSource": "salesforce",
"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 Salesforce Account
"crmRecordId": "0015g00000abc123",
"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 Account record using that id, and you have a closed-loop pipeline from Sales-qualified to onboarded without anyone ever touching the FML analyst console.
Source badge in the analyst console: cases minted via this flow show a blue API · Salesforce badge in the dashboard so analysts immediately know the case originated from sales, not the upload portal.
Live onboarding progress → case.onboarding_progress.changed. Subscribe an endpoint to this event and you get a de-duplicated, sales-safe snapshot every time the customer meaningfully advances — a plain-language journey stage (e.g. collecting_documents → awaiting_id_verification → decision_made) plus a per-person roster of each director / UBO / signatory with their name, role and a coarse verification status (link_sent / in_progress / action_needed / verified), and a { verified, total } summary. It carries the same crmRecordId you supplied, so you can paint a live progress bar and per-contact checklist straight onto the Account / Opportunity. It fires only on a real change, so you won't get a flood of duplicate updates — and it never includes EDD, risk, screening adjudication or any contact PII beyond the display name.
8. Troubleshooting
401 invalid_client— the named credential's client secret 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.409 idempotency_key_conflict— the sameIdempotency-Keywas previously used on a different endpoint; pick a fresh key.
For the full request/response contract see the Public API guide or the machine-readable OpenAPI spec.