Salesforce integration guide

One-click KYB Onboarding from Salesforce

Drop an Initiate KYB Onboarding button onto the Salesforce Account page layout. One click creates a case in First Mile Labs, sends the customer their tenant-branded magic-link invite, and starts streaming progress back to Salesforce via outbound webhooks — all without leaving the Account record.

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.

4. Quick-action button

Object Manager → Account → Buttons, Links & Actions → New Action. Action Type Lightning Web Component; component is a small wrapper that calls initiateOnboarding(recordId), surfaces the resulting customerPortalUrl as a toast, and refreshes the page so the new FML_Case_Id__c is visible. Add the action to the Account page layout in the position your sales team expects.

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-Key header — caller-supplied. Replays return the original status + body verbatim, with Idempotent-Replay: true. We use sfdc-{Account.Id} in the Apex sample so retries from a flaky network are safe.
  • (orgId, crmRecordId) dedup — pass crmRecordId: "{Account.Id}" and a second call for the same Account 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":       "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_documentsawaiting_id_verificationdecision_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 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.
  • 409 idempotency_key_conflict — the same Idempotency-Key was 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.