Integration Guide
Webhook Integration Guide
Push case events from First Mile Labs into Salesforce, HubSpot, Zapier, or any HTTPS endpoint you control. This guide covers everything you need to set up, configure, and verify a working connection.
Before you begin
Three things to line up before you open the Outbound Webhooks page in the analyst console. Doing them in this order avoids the most common setup mistakes.
- Decide who owns the receiver. Pick which system on your side will accept the webhook (Salesforce Apex REST, a Cloud Function in front of HubSpot, a Zapier Catch Raw Hook, or a service you maintain). It must be reachable on the public internet via HTTPS — private/loopback URLs are rejected.
- Have a place to store the shared secret. The secret is shown onceat endpoint creation time. Pick the secret store you'll paste it into (Salesforce Custom Setting, Secrets Manager, environment variable, etc.) before you click Save.
- Know which events you want. A full activity-timeline of event types is available — see section 3. Subscribing to fewer events keeps your receiver's load lower and makes the audit log easier to scan. You can change the subscription list any time from the same page.
1. Overview
First Mile Labs sends an HTTPS POST to every endpoint you register when a case lifecycle event occurs. The event surface is a full activity timeline — case lifecycle, risk and routing decisions, EDD, identity verification, monitoring, per-document processing, person-linking, screening hit triage, outreach, escalation, and ownership changes. Each request is signed with HMAC-SHA256 so your receiver can prove the payload originated from us and was not tampered with in transit.
- Direction: outbound from First Mile Labs to your system. We never poll your CRM.
- Transport: HTTPS only. URLs that resolve to private, loopback, link-local, or cloud-metadata addresses are rejected.
- Signing: HMAC-SHA256 over the raw request body, sent in
X-FML-Signature: sha256=<hex>. - Latency budget: your endpoint must respond with
2xxwithin 10 seconds, or we treat the delivery as failed and schedule a retry. - Tenant scope: events are emitted per organisation. Each org configures its own endpoints, secrets, and event subscriptions.
2. Set up an endpoint
Endpoint configuration lives in the analyst console under Compliance Configuration → Outbound Webhooks. You will need analyst-level permissions on your tenant.
- Click Add Endpoint. A form appears with URL, label, secret, and event-type fields.
- Enter the destination URL. This is the URL of the receiver you control — for example
https://your-instance.my.salesforce.com/services/apexrest/fml/webhook. It must be HTTPS-reachable from the public internet. - Add a label. Free text such as "Salesforce — production". Used in the dashboard and delivery list.
- Provide or generate a shared secret. Leave the field blank to have us generate a 64-character hex secret automatically. The secret is shown once, immediately after creation — copy it into your CRM's secret store at that moment, because we only ever store it encrypted at rest.
- Pick the event types you want delivered. By default the full event list is subscribed. Untick any you do not need — for example a finance system may only want
case.decision.made. - (Optional) Click Send test ping. Pre-save reachability check — sends an
event: "ping"payload to the URL with the secret you supplied, so you can confirm DNS, TLS, and your receiver respond before saving. - Save. The endpoint is now active.
3. Events & payloads
Every payload is JSON. The same envelope wraps every event:
event— string, the event type.eventId— UUID v4, unique per event and stable across retries / manual redelivery. Use it as your idempotency key (also exposed as theX-FML-Event-Idheader).eventVersion— integer contract version, currently1. Bumped only on a breaking change; see section 9.timestamp— ISO 8601 UTC, when the event was emitted. HMAC-covered, so you can use it for replay-window checks safely.orgId— your First Mile Labs organisation identifier.crmRecordId— present whenevercaseIdis present. Echoes back whatever your CRM stored on the case viaPOST /api/cases/:caseId/crm-record-idat creation time, so you can update the right record without a lookup.nullif you have not stored one.
The envelope is then merged with the event-specific fields below. Example for case.decision.made:
{
"event": "case.decision.made",
"eventId": "0d4e6b3c-9e4f-4a4a-b3a1-3f6f8c9b1a22",
"eventVersion": 1,
"timestamp": "2026-05-14T09:18:42.117Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"outcome": "approved",
"notes": "All documentation verified. UBO IDV passed.",
"analystEmail": "alex.kim@demobank.com",
"decidedAt": "2026-05-14T09:18:42.052Z",
"companyName": "Acme Holdings Ltd"
}HTTP request headers on every delivery:
Content-Type: application/jsonX-FML-Signature: sha256=<hex>— HMAC-SHA256 of the raw body, computed with your endpoint's shared secret.X-FML-Event: <event-type>— convenience header so receivers can route without parsing the body first.X-FML-Event-Id: <uuid>— stable across retries and manual redelivery. Use this as your idempotency key — it also appears in the body aseventId.X-FML-Event-Version: 1— contract version. We bump it only on a breaking change to the envelope or signature scheme; see section 9.X-FML-Timestamp: <ISO 8601>— emission time, mirrorstimestampin the body. Use it to enforce a replay window (we recommend 5 minutes).X-FML-Delivery-Id: <int>— internal delivery row id, useful when you want to ask our support team to look up a specific failed delivery. Stable across retries.X-FML-Attempt: <n>— 1-indexed attempt counter.1on the initial try,2on the first automatic retry, and so on; manual redeliveries continue to increment from the same delivery row rather than resetting.User-Agent: FirstMileLabs-Webhooks/1.0
Each event type and its payload fields:
.v1.json); a future eventVersion bump will ship a .v2.json alongside without breaking consumers.event field with full type narrowing — no codegen required: webhooks.d.ts ↓ (manifest at /types/index.json). The file exports one interface per event (CaseCreatedV1, CaseDecisionMadeV1, …), a discriminated union FmlWebhookEvent, and a lookup map FmlWebhookEventMap. Regenerated from the JSON Schemas on every build, so it never drifts.A new case is opened — manual analyst creation, customer doc upload, or application form submission.
| Field | Type | Notes |
|---|---|---|
caseId | string | First Mile Labs case identifier (e.g. CS-2026-00471). |
companyName | string | null | Subject company name when known. |
companyNumber | string | null | Registry number when collected (e.g. Companies House number). |
country | string | null | ISO country / jurisdiction of the subject company. |
customerEmail | string | null | Email of the customer the case belongs to. |
source | string | One of "manual", "doc_upload", "application_form". |
createdBy | string | Analyst email — only present on manual creation. |
Sample payload
{
"event": "case.created",
"timestamp": "2026-05-14T09:18:42.117Z",
"orgId": "org_demo_bank",
"crmRecordId": null,
"caseId": "CS-2026-00471",
"companyName": "Acme Holdings Ltd",
"companyNumber": "12345678",
"country": "GB",
"customerEmail": "ops@acmeholdings.example",
"source": "manual",
"createdBy": "alex.kim@demobank.com"
}The customer has finished their part — application form submitted or customer review confirmed.
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
companyName | string | null | — |
country | string | null | — |
customerEmail | string | null | — |
source | string | "application_form" or "doc_upload". |
Sample payload
{
"event": "case.submitted",
"timestamp": "2026-05-14T09:22:11.482Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"companyName": "Acme Holdings Ltd",
"country": "GB",
"customerEmail": "ops@acmeholdings.example",
"source": "application_form"
}An analyst has approved or declined the case.
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
outcome | string | "approved" or "declined". |
notes | string | Free-text decision notes captured in the analyst panel. |
analystEmail | string | Analyst who made the decision. |
decidedAt | string (ISO 8601) | Decision timestamp. |
companyName | string | null | — |
Sample payload
{
"event": "case.decision.made",
"timestamp": "2026-05-14T11:04:08.961Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"outcome": "approved",
"notes": "All documentation verified. UBO IDV passed.",
"analystEmail": "alex.kim@demobank.com",
"decidedAt": "2026-05-14T11:04:08.901Z",
"companyName": "Acme Holdings Ltd"
}Lifecycle transitions — initial submission, decision, escalation to senior, return from senior review.
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
companyName | string | null | — |
status | string | New status (e.g. "approved", "declined", "pending_senior", "returned"). |
previousStatus | string | Status before the transition. |
changedBy | string | Email of the actor (analyst or customer). |
recommendation | string | Senior reviewer recommendation — only on returns from senior review. |
Sample payload
{
"event": "case.status.changed",
"timestamp": "2026-05-14T11:04:09.014Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"companyName": "Acme Holdings Ltd",
"status": "approved",
"previousStatus": "in_review",
"changedBy": "alex.kim@demobank.com"
}A customer corrected extracted data during review, or an analyst manually overrode an errored document (approve / escalate).
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
source | string | "customer_review" or "analyst_override". |
amendmentCount | number | Customer review only — number of fields the customer changed. |
amendments | array | Customer review only — array of {section, field, originalValue, correctedValue} entries. |
documentId | number | Analyst override only — the document that was overridden. |
field | string | Analyst override only — always "document_status". |
originalValue | string | Analyst override only — typically "error". |
correctedValue | string | Analyst override only — "approved" or "escalated". |
note | string | Manual approval note. |
reason | string | Escalation reason. |
by | string | Analyst email. |
Sample payload
{
"event": "data.corrected",
"timestamp": "2026-05-14T09:24:55.220Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"source": "customer_review",
"amendmentCount": 2,
"amendments": [
{
"section": "company",
"field": "registeredAddress",
"originalValue": "10 High Street, London EC1A 1AA",
"correctedValue": "10 High Street, London EC1A 1BB"
},
{
"section": "directors",
"field": "0.fullName",
"originalValue": "J Smith",
"correctedValue": "Jane Smith"
}
]
}One or more documents were uploaded to a case (single emit per upload batch).
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
documentIds | number[] | Internal IDs of every uploaded document. |
fileCount | number | Number of files in the batch. |
filenames | string[] | Original filenames. |
Sample payload
{
"event": "document.uploaded",
"timestamp": "2026-05-14T09:19:30.005Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"documentIds": [4711, 4712, 4713],
"fileCount": 3,
"filenames": [
"certificate-of-incorporation.pdf",
"register-of-directors.pdf",
"passport-jane-smith.jpg"
]
}A sanctions / PEP / adverse media screening run finished for a case.
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
screened | number | Total subjects screened (company plus directors and shareholders). |
summary | array | Array of {name, role, status} — status is "clear", "match", "pep", or "sanctioned". |
timestamp | string (ISO 8601) | When screening completed. |
Sample payload
{
"event": "screening.completed",
"timestamp": "2026-05-14T09:25:14.770Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"screened": 3,
"summary": [
{ "name": "Acme Holdings Ltd", "role": null, "status": "clear" },
{ "name": "Jane Smith", "role": "director", "status": "clear" },
{ "name": "Maria Lopez", "role": "shareholder", "status": "pep" }
]
}The autonomous AI case review (AI screening) finished for a case — fired both for analyst-triggered streaming reviews and for background reviews kicked off automatically after form submission or customer review confirmation.
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
reviewId | number | Internal id of the case_ai_reviews row. |
companyName | string | null | — |
triggeredBy | string | Analyst email for analyst-triggered streaming runs (e.g. "alex.kim@demobank.com"), or a "system:form-submission:<email>" / "system:confirm-review:<email>" tag for background runs kicked off automatically. |
recommendation | string | AI recommendation — one of "Approve", "Refer", or "Decline". |
confidence | string | Model confidence in the recommendation — one of "High", "Medium", or "Low". |
riskFactorCount | number | Number of risk factors the AI surfaced. |
riskFactors | array | Array of {text, severity} where severity is "HIGH", "MEDIUM", or "LOW". |
completedAt | string (ISO 8601) | When the AI review finished. |
Sample payload
{
"event": "ai_review.completed",
"timestamp": "2026-05-14T09:31:02.418Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"reviewId": 8421,
"companyName": "Acme Holdings Ltd",
"triggeredBy": "alex.kim@demobank.com",
"recommendation": "Approve",
"confidence": "High",
"riskFactorCount": 2,
"riskFactors": [
{ "text": "UBO Maria Lopez returned a PEP screening hit pending adjudication", "severity": "MEDIUM" },
{ "text": "Company registered less than 12 months ago", "severity": "LOW" }
],
"completedAt": "2026-05-14T09:31:02.301Z"
}A single uploaded document finished AI extraction successfully (one emit per document, complement of the batch document.uploaded).
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
documentId | number | Internal id of the documents row. |
documentType | string | AI-classified document type, e.g. "Certificate of Incorporation". |
filename | string | Original filename as uploaded. |
status | string | Always "complete" — error completions do not fire this event. |
confidence | string | "High", "Medium", or "Low". |
warningCount | number | Number of AI warnings on the extraction. |
missingFieldCount | number | Number of expected fields the AI could not extract. |
Sample payload
{
"event": "document.processed",
"timestamp": "2026-05-14T09:19:48.221Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"documentId": 4711,
"documentType": "Certificate of Incorporation",
"filename": "certificate-of-incorporation.pdf",
"status": "complete",
"confidence": "High",
"warningCount": 0,
"missingFieldCount": 0
}The risk-scoring engine produced (or refreshed) a composite score for a case.
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
assessmentId | number | Internal id of the risk_assessments row. |
configId | number | Active risk_model_config row this score was computed against. |
compositeScore | number | 0–100 composite score. |
bandId | string | Risk band — "low", "medium", "high", "prohibited". |
bandLabel | string | Human-readable band label. |
assessedBy | string | "system" for automatic runs, analyst email for manual recompute. |
assessedAt | string (ISO 8601) | When the score was committed. |
Sample payload
{
"event": "risk.assessed",
"timestamp": "2026-05-14T09:32:11.005Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"assessmentId": 3301,
"configId": 7,
"compositeScore": 27,
"bandId": "low",
"bandLabel": "Low",
"assessedBy": "system",
"assessedAt": "2026-05-14T09:32:10.992Z"
}A decision-policy run completed for a case (every time the rule engine evaluates the active policy).
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
policyId | number | Active decision_policies row id. |
policyName | string | Human-readable name of the policy that was matched. |
matchedRuleIndex | number | null | Zero-indexed rule that matched, or null if the default branch fired. |
outcome | string | Routing outcome (e.g. "AUTO_APPROVE", "REVIEW_LEVEL_1", "DECLINE"). |
evaluatedAt | string (ISO 8601) | — |
Sample payload
{
"event": "routing.evaluated",
"timestamp": "2026-05-14T09:32:11.118Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"policyId": 4,
"policyName": "EU MSB — standard routing",
"matchedRuleIndex": 1,
"outcome": "REVIEW_LEVEL_1",
"evaluatedAt": "2026-05-14T09:32:11.110Z"
}An analyst marked an Enhanced Due Diligence requirement outstanding/received/waived (or updated its note).
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
reqKey | string | Stable key for the requirement (e.g. "source_of_funds"). |
label | string | Human-readable requirement label. |
status | string | "outstanding", "received", or "waived". |
previousStatus | string | null | Status before the change. |
note | string | Free-text analyst note. |
updatedBy | string | Analyst email. |
Sample payload
{
"event": "edd.requirements.changed",
"timestamp": "2026-05-14T10:14:02.331Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"reqKey": "source_of_funds",
"label": "Source of Funds evidence",
"status": "received",
"previousStatus": "outstanding",
"note": "Bank statement received and reviewed.",
"updatedBy": "alex.kim@demobank.com"
}An EDD evidence-request email was sent to the customer (one emit per send).
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
recipientEmail | string | — |
itemCount | number | — |
items | array | Array of {key, label} for each outstanding EDD item included in the email. |
sentBy | string | Analyst email. |
sentAt | string (ISO 8601) | — |
Sample payload
{
"event": "edd.requested",
"timestamp": "2026-05-14T10:18:55.770Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"recipientEmail": "ops@acmeholdings.example",
"itemCount": 2,
"items": [
{ "key": "source_of_funds", "label": "Source of Funds evidence" },
{ "key": "source_of_wealth", "label": "Source of Wealth — UBO Maria Lopez" }
],
"sentBy": "alex.kim@demobank.com",
"sentAt": "2026-05-14T10:18:55.701Z"
}An identity-verification (Didit) link was sent to a real person on the case (one emit per email — deduped by personKey across roles).
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
recipientEmail | string | — |
recipientName | string | — |
personKey | string | Stable identity key (UUID v4) shared across the same real human within the case. |
roles | string[] | All roles this dispatch covers (e.g. ["director", "ubo"]). |
dispatchedBy | string | "system" for automatic dispatch, analyst email for manual sends. |
Sample payload
{
"event": "idv.dispatched",
"timestamp": "2026-05-14T09:25:18.220Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"recipientEmail": "jane.smith@example.com",
"recipientName": "Jane Smith",
"personKey": "00000000-0000-0000-0000-000000000001",
"roles": ["director", "ubo"],
"dispatchedBy": "system"
}A vendor (Didit) reported an identity verification result for one person on a case.
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
verificationType | string | Always "identity" today. |
vendorName | string | IDV vendor (e.g. "Didit"). |
position | number | Index of the person in the case roster. |
role | string | Role of the verified person on this case. |
outcome | string | "pass", "fail", or "manual_review". |
completedAt | string (ISO 8601) | — |
Sample payload
{
"event": "idv.completed",
"timestamp": "2026-05-14T09:48:02.118Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"verificationType": "identity",
"vendorName": "Didit",
"position": 1,
"role": "director",
"outcome": "pass",
"completedAt": "2026-05-14T09:48:02.001Z"
}A Didit dispatch was suppressed because an accepted Passport/ID document is already on file for that person — the IDV slot is satisfied without an email.
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
personName | string | — |
personEmail | string | null | — |
personKey | string | — |
roles | string[] | — |
satisfiedByDocId | number | Internal id of the Passport/ID document that satisfied IDV. |
Sample payload
{
"event": "idv.satisfied_by_document",
"timestamp": "2026-05-14T09:21:11.118Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"personName": "Jane Smith",
"personEmail": "jane.smith@example.com",
"personKey": "00000000-0000-0000-0000-000000000001",
"roles": ["director"],
"satisfiedByDocId": 4713
}A perpetual-monitoring run produced a new alert against an existing case.
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
companyName | string | null | — |
runId | number | Internal id of the monitoring run that produced the alert. |
alertType | string | Alert category (e.g. "sanctions_hit", "pep_change", "adverse_media"). |
severity | string | "low", "medium", or "high". |
title | string | Short alert title. |
detail | string | Longer alert detail. |
Sample payload
{
"event": "monitoring.alert.created",
"timestamp": "2026-05-14T03:00:14.880Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"companyName": "Acme Holdings Ltd",
"runId": 5001,
"alertType": "sanctions_hit",
"severity": "high",
"title": "New OpenSanctions hit — Maria Lopez",
"detail": "PEP designation added since last screening run."
}A perpetual-monitoring run finished for a case (regardless of whether any alerts were generated).
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
runId | number | — |
triggeredBy | string | "scheduled" or analyst email for ad-hoc runs. |
alertsGenerated | number | Number of new alerts produced by this run. |
checksRun | array | Array of {check, status, subjects} per check that ran. |
Sample payload
{
"event": "monitoring.run.completed",
"timestamp": "2026-05-14T03:00:15.001Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"runId": 5001,
"triggeredBy": "scheduled",
"alertsGenerated": 1,
"checksRun": [
{ "check": "sanctions", "status": "ok", "subjects": 3 },
{ "check": "adverse_media", "status": "ok", "subjects": 3 }
]
}AI per-hit triage completed for an OpenSanctions hit or adverse-media article. The hitId is the raw OpenSanctions id, or "am:<id>" for adverse-media.
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
hitId | string | Hit identifier — raw OpenSanctions id, or "am:<adverse_media_results.id>". |
subjectName | string | Subject this hit is associated with. |
confidence | number | 0–100 AI confidence score. |
disposition | string | "false_positive", "true_match", or "escalate". |
shortReason | string | One-line analyst-facing rationale. |
model | string | Anthropic model used (e.g. "claude-opus-4-5"). |
promptVersion | string | Prompt version, currently "1". |
Sample payload
{
"event": "screening.hit.triaged",
"timestamp": "2026-05-14T09:25:48.118Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"hitId": "NK-12345",
"subjectName": "Maria Lopez",
"confidence": 92,
"disposition": "true_match",
"shortReason": "Strong identity match with adverse-media context.",
"model": "claude-opus-4-5",
"promptVersion": "1"
}An analyst linked a case-side subject to a tenant Person row (Cross-Case Identity Linking, Task #79).
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
personId | number | persons.id of the canonical Person row. |
personKey | string | The case-side personKey that was linked. |
displayName | string | — |
email | string | null | — |
roles | string[] | Roles this subject holds on the case. |
linkedBy | string | Analyst email. |
Sample payload
{
"event": "person.linked",
"timestamp": "2026-05-14T09:24:01.221Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"personId": 7001,
"personKey": "00000000-0000-0000-0000-000000000001",
"displayName": "Jane Smith",
"email": "jane.smith@example.com",
"roles": ["director"],
"linkedBy": "alex.kim@demobank.com"
}An analyst removed an active case-to-Person link (soft-delete via unlinked_at).
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
personId | number | — |
personKey | string | — |
unlinkedBy | string | Analyst email. |
reason | string | null | Optional analyst reason. |
Sample payload
{
"event": "person.unlinked",
"timestamp": "2026-05-14T09:25:00.118Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"personId": 7001,
"personKey": "00000000-0000-0000-0000-000000000001",
"unlinkedBy": "alex.kim@demobank.com",
"reason": "Wrong individual on initial link."
}An analyst confirmed "no prior match" for a subject — a new Person row was created and linked.
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
personId | number | newly-created persons.id. |
personKey | string | — |
displayName | string | — |
email | string | null | — |
resolvedBy | string | Analyst email. |
Sample payload
{
"event": "person.marked_new",
"timestamp": "2026-05-14T09:24:55.221Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"personId": 7002,
"personKey": "00000000-0000-0000-0000-000000000001",
"displayName": "Jane Smith",
"email": "jane.smith@example.com",
"resolvedBy": "alex.kim@demobank.com"
}An analyst attached a Passport/ID document on a case to a Person profile so it is reusable on future cases within the tenant validity window.
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
personId | number | — |
documentId | number | — |
documentType | string | Document type (typically "Passport" or "Government ID"). |
attachedBy | string | Analyst email. |
Sample payload
{
"event": "person.document_attached",
"timestamp": "2026-05-14T09:26:11.880Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"personId": 7001,
"documentId": 4713,
"documentType": "Passport",
"attachedBy": "alex.kim@demobank.com"
}A customer-facing outreach email was sent (e.g. document request) — one emit per send.
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
kind | string | Email kind, e.g. "document_request". |
recipientEmail | string | — |
subject | string | Email subject line. |
itemCount | number | — |
items | array | Array of {key, label} for each outstanding item included in the email. |
sentBy | string | Analyst email. |
Sample payload
{
"event": "outreach.email.sent",
"timestamp": "2026-05-14T10:02:18.881Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"kind": "document_request",
"recipientEmail": "ops@acmeholdings.example",
"subject": "Additional documentation requested — Acme Holdings Ltd",
"itemCount": 2,
"items": [
{ "key": "register_of_directors", "label": "Register of Directors" },
{ "key": "proof_of_address", "label": "Proof of Address — UBO Maria Lopez" }
],
"sentBy": "alex.kim@demobank.com"
}An analyst escalated a case for senior review (companion to case.status.changed; carries the analyst note that case.status.changed deliberately omits).
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
companyName | string | null | — |
note | string | Analyst-supplied escalation note. |
raisedBy | string | Analyst email. |
Sample payload
{
"event": "escalation.created",
"timestamp": "2026-05-14T10:42:18.221Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"companyName": "Acme Holdings Ltd",
"note": "Senior review requested — high-risk jurisdiction with PEP-linked UBO.",
"raisedBy": "alex.kim@demobank.com"
}A senior reviewer responded to an escalation with approve/decline/refer (companion to case.status.changed; carries the senior note + recommendation).
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
companyName | string | null | — |
recommendation | string | "approve", "decline", or "refer". |
note | string | Senior reviewer note. |
respondedBy | string | Senior reviewer email. |
Sample payload
{
"event": "escalation.responded",
"timestamp": "2026-05-14T10:55:01.118Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"companyName": "Acme Holdings Ltd",
"recommendation": "approve",
"note": "Approved on review — EDD complete, PEP adjudicated as cleared.",
"respondedBy": "senior-manager@demobank.com"
}A case owner was assigned, reassigned, or cleared (case-row branch only — form-draft owner changes do not fire).
| Field | Type | Notes |
|---|---|---|
caseId | string | — |
previousOwner | object | null | Previous owner {name, email}, or null if previously unassigned. |
newOwner | object | null | New owner {name, email}, or null on unassign. |
changedBy | string | null | Analyst email of the actor. |
Sample payload
{
"event": "owner.changed",
"timestamp": "2026-05-14T09:23:11.005Z",
"orgId": "org_demo_bank",
"crmRecordId": "0035g00000Abc12AAA",
"caseId": "CS-2026-00471",
"previousOwner": null,
"newOwner": { "name": "Alex Kim", "email": "alex.kim@demobank.com" },
"changedBy": "alex.kim@demobank.com"
}4. Verify the signature
Signature verification is mandatory in production. Without it, anyone who learns your URL can forge a payload. The check is three steps:
- Read the
X-FML-Signatureheader. Reject the request if it is missing or does not start withsha256=. - Compute
HMAC-SHA256(rawBody, sharedSecret)and hex-encode it. - Constant-time compare
"sha256=" + hexagainst the header. If it does not match, return401. - Replay protection: reject the request if
now - X-FML-Timestamp > 5 minutes(or whichever window your security team prefers). The body'stimestampfield carries the same value and is covered by the HMAC, so an attacker cannot tamper with it without invalidating the signature.
express.raw(); in Flask, use request.data(not request.get_json()) for the HMAC step.5. Salesforce — full worked example
The recommended Salesforce pattern is an Apex REST resource that verifies the HMAC, then enqueues a Queueable to do the CRM updates asynchronously. This keeps you well inside the 10-second response budget.
5.1 Salesforce-side prep
- Create a Custom Setting called
FmlWebhookConfig__cwith a single text fieldSecret__c. Paste the secret you got from First Mile Labs into the org-default record. - Add custom fields on Account:
FML_Case_Id__c(text, external ID, unique),FML_Status__c(text),FML_Customer_Email__c(email),FML_Decision_At__c(datetime). - Make the Apex REST resource public via a Site or Connected App. The endpoint must be reachable without a Salesforce session, since First Mile Labs authenticates by HMAC, not OAuth.
5.2 Receiver class
// Salesforce Apex REST endpoint that accepts First Mile Labs webhooks.
// Path will be: https://your-instance.my.salesforce.com/services/apexrest/fml/webhook
@RestResource(urlMapping='/fml/webhook')
global with sharing class FmlWebhookReceiver {
// Stored in Custom Settings or Named Credentials, never hard-coded.
private static final String FML_SECRET =
FmlWebhookConfig__c.getInstance().Secret__c;
@HttpPost
global static void receive() {
RestRequest req = RestContext.request;
RestResponse res = RestContext.response;
// 1. Signature header is mandatory.
String sigHeader = req.headers.get('X-FML-Signature');
String eventType = req.headers.get('X-FML-Event');
String body = req.requestBody.toString();
if (String.isBlank(sigHeader) || !sigHeader.startsWith('sha256=')) {
res.statusCode = 401; return;
}
// 2. Compute HMAC-SHA256 over the raw body and constant-time compare.
Blob mac = Crypto.generateMac(
'HmacSHA256',
Blob.valueOf(body),
Blob.valueOf(FML_SECRET)
);
String expected = 'sha256=' + EncodingUtil.convertToHex(mac);
if (!constantTimeEquals(expected, sigHeader)) {
res.statusCode = 401; return;
}
// 3. Acknowledge fast — heavy work goes to a queueable job.
Map<String,Object> payload = (Map<String,Object>) JSON.deserializeUntyped(body);
System.enqueueJob(new FmlWebhookProcessor(eventType, payload));
res.statusCode = 200;
res.responseBody = Blob.valueOf('{"ok":true}');
}
private static Boolean constantTimeEquals(String a, String b) {
if (a.length() != b.length()) return false;
Integer diff = 0;
for (Integer i = 0; i < a.length(); i++) {
diff |= a.charAt(i) ^ b.charAt(i);
}
return diff == 0;
}
}5.3 Queueable processor
// Queueable that maps a First Mile Labs event into Salesforce CRM objects.
public class FmlWebhookProcessor implements Queueable {
private final String eventType;
private final Map<String,Object> payload;
public FmlWebhookProcessor(String eventType, Map<String,Object> payload) {
this.eventType = eventType;
this.payload = payload;
}
public void execute(QueueableContext ctx) {
String caseId = (String) payload.get('caseId');
String crmRecordId = (String) payload.get('crmRecordId');
// crmRecordId is the Salesforce Account / Opportunity ID you stored on
// the FML case at creation time. If present we update in place; if
// null we create a new record and write the new Id back to FML.
if (eventType == 'case.created') {
Account a = (crmRecordId != null)
? new Account(Id = crmRecordId)
: new Account(Name = (String) payload.get('companyName'));
a.FML_Case_Id__c = caseId;
a.FML_Status__c = 'in_progress';
a.FML_Customer_Email__c = (String) payload.get('customerEmail');
upsert a;
} else if (eventType == 'case.decision.made') {
Account a = [SELECT Id FROM Account WHERE FML_Case_Id__c = :caseId LIMIT 1];
a.FML_Status__c = (String) payload.get('outcome');
a.FML_Decision_At__c = (Datetime) JSON.deserialize(
'"' + payload.get('decidedAt') + '"', Datetime.class
);
update a;
}
// Add branches for case.status.changed, screening.completed, etc.
}
}5.4 Wire up crmRecordId round-trip
The first time a case is created, your processor inserts a Salesforce Account and gets back a Salesforce Id. Send that Id back to First Mile Labs once, and every subsequent webhook for the same case will arrive with crmRecordId populated — no lookup needed.
# From your Salesforce processor, after the initial Account insert:
curl -X POST https://app.firstmilelabs.com/api/cases/CS-2026-00471/crm-record-id \
-H "Authorization: Bearer <your-fml-api-token>" \
-H "Content-Type: application/json" \
-d '{"crmRecordId": "0035g00000Abc12AAA"}'6. HubSpot, Zapier, and any other system
First Mile Labs does not ship CRM-specific connectors. Anything that can host an HTTPS endpoint and verify an HMAC works the same way as the Salesforce example above.
- HubSpot: point the webhook at a Workflow webhook action via a small intermediary (Cloud Function, AWS Lambda, etc.) that verifies the HMAC and forwards the validated payload into HubSpot's API. HubSpot workflows themselves cannot verify HMAC signatures.
- Zapier: use a Catch Raw Hook trigger so you receive the unparsed body, then verify the signature in a Code step before mapping fields into a Zap. The default Catch Hook trigger reformats the body and breaks the HMAC.
- Custom service: drop in either of the receivers below.
Node / Express
// Generic Express receiver — drop in for any Node service.
import express from 'express';
import { createHmac, timingSafeEqual } from 'crypto';
const app = express();
const SECRET = process.env.FML_WEBHOOK_SECRET;
const REPLAY_WINDOW_MS = 5 * 60_000; // 5 minutes
const seenEventIds = new Set(); // swap for Redis in production
// IMPORTANT: capture the raw body BEFORE express.json() reformats it,
// otherwise the HMAC won't match (whitespace and key order matter).
app.post(
'/webhooks/fml',
express.raw({ type: 'application/json' }),
(req, res) => {
const sigHeader = req.headers['x-fml-signature'] || '';
if (!sigHeader.startsWith('sha256=')) return res.sendStatus(401);
const expected = 'sha256=' + createHmac('sha256', SECRET)
.update(req.body)
.digest('hex');
const a = Buffer.from(sigHeader);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
// Replay window — reject events older than 5 minutes.
const ts = Date.parse(req.headers['x-fml-timestamp'] || '');
if (!ts || Math.abs(Date.now() - ts) > REPLAY_WINDOW_MS) {
return res.sendStatus(401);
}
// Idempotency — dedup on X-FML-Event-Id (stable across retries).
const eventId = req.headers['x-fml-event-id'];
if (eventId && seenEventIds.has(eventId)) return res.sendStatus(200);
if (eventId) seenEventIds.add(eventId);
const event = req.headers['x-fml-event'];
const payload = JSON.parse(req.body.toString('utf8'));
// Acknowledge fast (under 10s) — defer heavy work to a queue.
res.sendStatus(200);
handleAsync(event, payload).catch(err =>
console.error('webhook processing failed', err));
}
);Python / Flask
# Generic Flask receiver.
import hmac, hashlib, os
from datetime import datetime, timezone
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['FML_WEBHOOK_SECRET'].encode()
REPLAY_WINDOW_S = 5 * 60 # 5 minutes
seen_event_ids = set() # swap for Redis in production
@app.post('/webhooks/fml')
def fml_webhook():
sig_header = request.headers.get('X-FML-Signature', '')
if not sig_header.startswith('sha256='):
abort(401)
expected = 'sha256=' + hmac.new(SECRET, request.data, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig_header):
abort(401)
# Replay window.
ts_header = request.headers.get('X-FML-Timestamp', '')
try:
ts = datetime.fromisoformat(ts_header.replace('Z', '+00:00'))
except ValueError:
abort(401)
if abs((datetime.now(timezone.utc) - ts).total_seconds()) > REPLAY_WINDOW_S:
abort(401)
# Idempotency on X-FML-Event-Id (stable across retries).
event_id = request.headers.get('X-FML-Event-Id')
if event_id and event_id in seen_event_ids:
return ('', 200)
if event_id:
seen_event_ids.add(event_id)
event = request.headers.get('X-FML-Event')
payload = request.get_json()
# Ack fast, defer heavy work to a worker queue.
return ('', 200)7. Test the connection
There are three independent test paths, used at different points in the lifecycle:
7.1 Pre-save reachability ping
On the Add Endpoint form, click Send test ping before you save. This POSTs an event: "ping" payload to the URL you typed, signed with the secret you typed (or a freshly-generated one if blank). Use this to confirm DNS, TLS, and your receiver are wired up before you commit a misconfigured endpoint.
The ping payload looks like:
{
"event": "ping",
"eventId": "0d4e6b3c-9e4f-4a4a-b3a1-3f6f8c9b1a22",
"eventVersion": 1,
"timestamp": "2026-05-14T09:18:42.117Z",
"orgId": "org_demo_bank",
"message": "This is a test ping from First Mile Labs webhook infrastructure."
}7.2 Post-save endpoint test
On any saved endpoint, click the Test button. This sends the same ping payload, but signed with the real stored secret— so it also proves your receiver is verifying signatures correctly. The result (HTTP status and first 500 bytes of the response body) appears next to the button.
7.3 End-to-end with a real event
The cleanest dry-run uses a real lifecycle event:
- In the analyst console, create a new test case (or open an existing one).
- Take an action that emits an event — e.g. add a document to fire
document.uploaded, or approve the case to firecase.decision.made. - Open Outbound Webhooks → Recent Deliveries. You will see the delivery row appear within a second, with HTTP status, attempt count, and an expandable view of the full payload and the response body your receiver returned.
- Filter by endpoint to isolate just that receiver's traffic.
8. Retries & manual redelivery
Any non-2xx response, network error, or response that takes longer than 10 seconds is treated as a failed delivery. Failed deliveries are retried five times on a fixed schedule:
- 1 second after the initial failure
- 5 seconds
- 30 seconds
- 5 minutes
- 30 minutes
After the fifth retry the delivery is marked failed and no further automatic attempts are made. Every attempt — including the response body — is recorded in the delivery history for audit, and you can press Redeliver in the dashboard at any time to trigger a manual replay using the original payload.
X-FML-Event-Id (also present in the body as eventId) — it is generated once when the event is emitted and stays the same across every retry and every manual replay of the same delivery row. Store seen event ids and short-circuit duplicates with a 200 OK.Status codes and how we react:
| Receiver returns | We treat it as | Then |
|---|---|---|
2xx within 10 s | Delivered | Mark the delivery delivered. No further attempts. |
4xx (any) | Failure | Retry on the schedule above. We do not short-circuit 4xx — receivers occasionally return 401 during a secret rotation and recover on the next attempt. |
5xx (any) | Failure | Retry on the schedule above. |
| No response in 10 s | Failure (timeout) | Retry on the schedule above. Logged with HTTP 0. |
| Network error / DNS failure | Failure | Retry on the schedule above. Logged with HTTP 0 and the underlying error message. |
9. Using with an iPaaS (Workato, MuleSoft, Boomi, Tray)
The contract above is designed to work cleanly with mainstream integration platforms. There is no FML-specific connector to install — point an iPaaS HTTP trigger at your endpoint URL, configure HMAC verification with your shared secret, and you are done.
- Trigger type: use the platform's HTTP / Webhook trigger that exposes the raw request body and the full set of HTTP headers. Triggers that auto-parse JSON before you can hash it (e.g. Zapier's default Catch Hook, Workato's older HTTP webhook trigger) will break the HMAC — pick the raw variant.
- Secret storage: store the shared secret in the platform's secret manager (Workato connection vault, MuleSoft Secure Configuration Properties, Boomi Vault) — never in plain recipe / process state.
- Idempotency: deduplicate on
X-FML-Event-Id. Most platforms have a built-in "remember last N keys" or persistent recipe-state primitive — use it. - Replay window: enforce
now - X-FML-Timestamp ≤ 5 minutesas the first step in your recipe. - Versioning: branch recipes on
X-FML-Event-Version. Today it is1; if it ever becomes2, your1branch keeps running unchanged until you migrate. - Acknowledgement: respond
2xxwithin 10 s and run heavier downstream actions in an async recipe / queue. iPaaS platforms doing synchronous downstream calls inside the trigger will frequently breach the 10 s budget. - Failures: let our retry schedule handle transient errors — return
5xxwhen your downstream is unhealthy and we'll back off and retry. Manual re-runs are available from the analyst console under Outbound Webhooks → Recent Deliveries → Redeliver.
Compatibility policy
- Additive changes (new event types, new fields on existing payloads, new headers) do not bump
X-FML-Event-Version. Receivers that ignore unknown fields and headers will keep working. - Breaking changes (renames, removals, signature scheme changes) bump the version. We will email registered endpoint owners at least 60 days before a version bump and run both versions side-by-side during the transition window.
- Event names are stable. An event such as
case.approvedretains its meaning forever; if we need a different shape we publish a new event name (e.g.case.approved.v2) rather than mutating the old one.
10. Troubleshooting
| Symptom | Likely cause & fix |
|---|---|
| Endpoint creation rejected: "Webhook URL must not target a private or loopback address." | The URL resolves to a private, loopback, link-local, CGN, IPv6-local, or cloud-metadata address. SSRF protection blocks these. Use a public HTTPS URL or expose your service via a tunnel such as ngrok or Cloudflare Tunnel. |
| Test ping returns HTTP 0 with a network error. | Receiver did not respond within 10 seconds, the TLS handshake failed, or DNS could not resolve. Verify the URL with curl -X POST from outside your network. |
| Receiver returns 401 on every delivery. | HMAC mismatch. Almost always one of: (1) computing the HMAC over the parsed JSON instead of the raw bytes; (2) using the wrong secret (production vs. staging); (3) trimming or modifying the body before verification. |
| Deliveries are stuck on Pending retry. | Your receiver is returning a non-2xx status. Open the delivery row to see the response body — that is exactly what your endpoint sent back. Once you fix the receiver, the next retry will succeed automatically (or click Redeliver to force it now). |
| Receiver gets the same event twice. | Either an automatic retry succeeded after a soft-failure, or someone clicked Redeliver. Make your handler idempotent — see the callout in section 8. |
crmRecordId is always null. | You have not yet POSTed a CRM record id back via POST /api/cases/:caseId/crm-record-id. Do this once, immediately after your first case.created handler inserts the CRM record. |
| You stop receiving events for one type but others still arrive. | Someone unticked that event type when editing the endpoint. Open the endpoint in the dashboard and re-enable it. |
Need help? If a specific delivery is failing in production, share the delivery ID from the Recent Deliveries list with our support team and we can replay it from the audit trail. Contact us.