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.

Using an iPaaS like Workato? Skip the bespoke receiver — see the Workato recipe guide for an end-to-end FML → Workato → Salesforce walkthrough using the generic webhook trigger and HTTP connector. No custom Workato Connector required.

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.

  1. 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.
  2. 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.
  3. 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 2xx within 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.

  1. Click Add Endpoint. A form appears with URL, label, secret, and event-type fields.
  2. 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.
  3. Add a label. Free text such as "Salesforce — production". Used in the dashboard and delivery list.
  4. 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.
  5. 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.
  6. (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.
  7. Save. The endpoint is now active.
One-timeThe shared secret is shown only once at creation. If you lose it you must rotate by editing the endpoint and entering a new value — the previous secret is unrecoverable.

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 the X-FML-Event-Id header).
  • eventVersion — integer contract version, currently 1. 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 whenever caseId is present. Echoes back whatever your CRM stored on the case via POST /api/cases/:caseId/crm-record-id at creation time, so you can update the right record without a lookup. null if 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/json
  • X-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 as eventId.
  • 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, mirrors timestamp in 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. 1 on the initial try, 2 on 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:

CodegenEvery event below also has a machine-readable JSON Schema (Draft 2020-12) you can point an iPaaS connector or stub-code generator at. Browse the index at /schemas/webhooks/index.json or follow the Schema link on each event card. Schemas are versioned (.v1.json); a future eventVersion bump will ship a .v2.json alongside without breaking consumers.
TypeScriptHand-rolled Node/TypeScript receiver? Drop our ready-made ambient definitions into your project and switch on the 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.
case.createdEventSchema (v1) ↗

A new case is opened — manual analyst creation, customer doc upload, or application form submission.

FieldTypeNotes
caseIdstringFirst Mile Labs case identifier (e.g. CS-2026-00471).
companyNamestring | nullSubject company name when known.
companyNumberstring | nullRegistry number when collected (e.g. Companies House number).
countrystring | nullISO country / jurisdiction of the subject company.
customerEmailstring | nullEmail of the customer the case belongs to.
sourcestringOne of "manual", "doc_upload", "application_form".
createdBystringAnalyst 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"
}
case.submittedEventSchema (v1) ↗

The customer has finished their part — application form submitted or customer review confirmed.

FieldTypeNotes
caseIdstring
companyNamestring | null
countrystring | null
customerEmailstring | null
sourcestring"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"
}
case.decision.madeEventSchema (v1) ↗

An analyst has approved or declined the case.

FieldTypeNotes
caseIdstring
outcomestring"approved" or "declined".
notesstringFree-text decision notes captured in the analyst panel.
analystEmailstringAnalyst who made the decision.
decidedAtstring (ISO 8601)Decision timestamp.
companyNamestring | 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"
}
case.status.changedEventSchema (v1) ↗

Lifecycle transitions — initial submission, decision, escalation to senior, return from senior review.

FieldTypeNotes
caseIdstring
companyNamestring | null
statusstringNew status (e.g. "approved", "declined", "pending_senior", "returned").
previousStatusstringStatus before the transition.
changedBystringEmail of the actor (analyst or customer).
recommendationstringSenior 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"
}
data.correctedEventSchema (v1) ↗

A customer corrected extracted data during review, or an analyst manually overrode an errored document (approve / escalate).

FieldTypeNotes
caseIdstring
sourcestring"customer_review" or "analyst_override".
amendmentCountnumberCustomer review only — number of fields the customer changed.
amendmentsarrayCustomer review only — array of {section, field, originalValue, correctedValue} entries.
documentIdnumberAnalyst override only — the document that was overridden.
fieldstringAnalyst override only — always "document_status".
originalValuestringAnalyst override only — typically "error".
correctedValuestringAnalyst override only — "approved" or "escalated".
notestringManual approval note.
reasonstringEscalation reason.
bystringAnalyst 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"
    }
  ]
}
document.uploadedEventSchema (v1) ↗

One or more documents were uploaded to a case (single emit per upload batch).

FieldTypeNotes
caseIdstring
documentIdsnumber[]Internal IDs of every uploaded document.
fileCountnumberNumber of files in the batch.
filenamesstring[]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"
  ]
}
screening.completedEventSchema (v1) ↗

A sanctions / PEP / adverse media screening run finished for a case.

FieldTypeNotes
caseIdstring
screenednumberTotal subjects screened (company plus directors and shareholders).
summaryarrayArray of {name, role, status} — status is "clear", "match", "pep", or "sanctioned".
timestampstring (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"   }
  ]
}
ai_review.completedEventSchema (v1) ↗

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.

FieldTypeNotes
caseIdstring
reviewIdnumberInternal id of the case_ai_reviews row.
companyNamestring | null
triggeredBystringAnalyst 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.
recommendationstringAI recommendation — one of "Approve", "Refer", or "Decline".
confidencestringModel confidence in the recommendation — one of "High", "Medium", or "Low".
riskFactorCountnumberNumber of risk factors the AI surfaced.
riskFactorsarrayArray of {text, severity} where severity is "HIGH", "MEDIUM", or "LOW".
completedAtstring (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"
}
document.processedEventSchema (v1) ↗

A single uploaded document finished AI extraction successfully (one emit per document, complement of the batch document.uploaded).

FieldTypeNotes
caseIdstring
documentIdnumberInternal id of the documents row.
documentTypestringAI-classified document type, e.g. "Certificate of Incorporation".
filenamestringOriginal filename as uploaded.
statusstringAlways "complete" — error completions do not fire this event.
confidencestring"High", "Medium", or "Low".
warningCountnumberNumber of AI warnings on the extraction.
missingFieldCountnumberNumber 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
}
risk.assessedEventSchema (v1) ↗

The risk-scoring engine produced (or refreshed) a composite score for a case.

FieldTypeNotes
caseIdstring
assessmentIdnumberInternal id of the risk_assessments row.
configIdnumberActive risk_model_config row this score was computed against.
compositeScorenumber0–100 composite score.
bandIdstringRisk band — "low", "medium", "high", "prohibited".
bandLabelstringHuman-readable band label.
assessedBystring"system" for automatic runs, analyst email for manual recompute.
assessedAtstring (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"
}
routing.evaluatedEventSchema (v1) ↗

A decision-policy run completed for a case (every time the rule engine evaluates the active policy).

FieldTypeNotes
caseIdstring
policyIdnumberActive decision_policies row id.
policyNamestringHuman-readable name of the policy that was matched.
matchedRuleIndexnumber | nullZero-indexed rule that matched, or null if the default branch fired.
outcomestringRouting outcome (e.g. "AUTO_APPROVE", "REVIEW_LEVEL_1", "DECLINE").
evaluatedAtstring (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"
}
edd.requirements.changedEventSchema (v1) ↗

An analyst marked an Enhanced Due Diligence requirement outstanding/received/waived (or updated its note).

FieldTypeNotes
caseIdstring
reqKeystringStable key for the requirement (e.g. "source_of_funds").
labelstringHuman-readable requirement label.
statusstring"outstanding", "received", or "waived".
previousStatusstring | nullStatus before the change.
notestringFree-text analyst note.
updatedBystringAnalyst 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"
}
edd.requestedEventSchema (v1) ↗

An EDD evidence-request email was sent to the customer (one emit per send).

FieldTypeNotes
caseIdstring
recipientEmailstring
itemCountnumber
itemsarrayArray of {key, label} for each outstanding EDD item included in the email.
sentBystringAnalyst email.
sentAtstring (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"
}
idv.dispatchedEventSchema (v1) ↗

An identity-verification (Didit) link was sent to a real person on the case (one emit per email — deduped by personKey across roles).

FieldTypeNotes
caseIdstring
recipientEmailstring
recipientNamestring
personKeystringStable identity key (UUID v4) shared across the same real human within the case.
rolesstring[]All roles this dispatch covers (e.g. ["director", "ubo"]).
dispatchedBystring"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"
}
idv.completedEventSchema (v1) ↗

A vendor (Didit) reported an identity verification result for one person on a case.

FieldTypeNotes
caseIdstring
verificationTypestringAlways "identity" today.
vendorNamestringIDV vendor (e.g. "Didit").
positionnumberIndex of the person in the case roster.
rolestringRole of the verified person on this case.
outcomestring"pass", "fail", or "manual_review".
completedAtstring (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"
}
idv.satisfied_by_documentEventSchema (v1) ↗

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.

FieldTypeNotes
caseIdstring
personNamestring
personEmailstring | null
personKeystring
rolesstring[]
satisfiedByDocIdnumberInternal 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
}
monitoring.alert.createdEventSchema (v1) ↗

A perpetual-monitoring run produced a new alert against an existing case.

FieldTypeNotes
caseIdstring
companyNamestring | null
runIdnumberInternal id of the monitoring run that produced the alert.
alertTypestringAlert category (e.g. "sanctions_hit", "pep_change", "adverse_media").
severitystring"low", "medium", or "high".
titlestringShort alert title.
detailstringLonger 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."
}
monitoring.run.completedEventSchema (v1) ↗

A perpetual-monitoring run finished for a case (regardless of whether any alerts were generated).

FieldTypeNotes
caseIdstring
runIdnumber
triggeredBystring"scheduled" or analyst email for ad-hoc runs.
alertsGeneratednumberNumber of new alerts produced by this run.
checksRunarrayArray 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 }
  ]
}
screening.hit.triagedEventSchema (v1) ↗

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.

FieldTypeNotes
caseIdstring
hitIdstringHit identifier — raw OpenSanctions id, or "am:<adverse_media_results.id>".
subjectNamestringSubject this hit is associated with.
confidencenumber0–100 AI confidence score.
dispositionstring"false_positive", "true_match", or "escalate".
shortReasonstringOne-line analyst-facing rationale.
modelstringAnthropic model used (e.g. "claude-opus-4-5").
promptVersionstringPrompt 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"
}
person.linkedEventSchema (v1) ↗

An analyst linked a case-side subject to a tenant Person row (Cross-Case Identity Linking, Task #79).

FieldTypeNotes
caseIdstring
personIdnumberpersons.id of the canonical Person row.
personKeystringThe case-side personKey that was linked.
displayNamestring
emailstring | null
rolesstring[]Roles this subject holds on the case.
linkedBystringAnalyst 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"
}
person.unlinkedEventSchema (v1) ↗

An analyst removed an active case-to-Person link (soft-delete via unlinked_at).

FieldTypeNotes
caseIdstring
personIdnumber
personKeystring
unlinkedBystringAnalyst email.
reasonstring | nullOptional 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."
}
person.marked_newEventSchema (v1) ↗

An analyst confirmed "no prior match" for a subject — a new Person row was created and linked.

FieldTypeNotes
caseIdstring
personIdnumbernewly-created persons.id.
personKeystring
displayNamestring
emailstring | null
resolvedBystringAnalyst 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"
}
person.document_attachedEventSchema (v1) ↗

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.

FieldTypeNotes
caseIdstring
personIdnumber
documentIdnumber
documentTypestringDocument type (typically "Passport" or "Government ID").
attachedBystringAnalyst 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"
}
outreach.email.sentEventSchema (v1) ↗

A customer-facing outreach email was sent (e.g. document request) — one emit per send.

FieldTypeNotes
caseIdstring
kindstringEmail kind, e.g. "document_request".
recipientEmailstring
subjectstringEmail subject line.
itemCountnumber
itemsarrayArray of {key, label} for each outstanding item included in the email.
sentBystringAnalyst 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"
}
escalation.createdEventSchema (v1) ↗

An analyst escalated a case for senior review (companion to case.status.changed; carries the analyst note that case.status.changed deliberately omits).

FieldTypeNotes
caseIdstring
companyNamestring | null
notestringAnalyst-supplied escalation note.
raisedBystringAnalyst 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"
}
escalation.respondedEventSchema (v1) ↗

A senior reviewer responded to an escalation with approve/decline/refer (companion to case.status.changed; carries the senior note + recommendation).

FieldTypeNotes
caseIdstring
companyNamestring | null
recommendationstring"approve", "decline", or "refer".
notestringSenior reviewer note.
respondedBystringSenior 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"
}
owner.changedEventSchema (v1) ↗

A case owner was assigned, reassigned, or cleared (case-row branch only — form-draft owner changes do not fire).

FieldTypeNotes
caseIdstring
previousOwnerobject | nullPrevious owner {name, email}, or null if previously unassigned.
newOwnerobject | nullNew owner {name, email}, or null on unassign.
changedBystring | nullAnalyst 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:

  1. Read the X-FML-Signature header. Reject the request if it is missing or does not start with sha256=.
  2. Compute HMAC-SHA256(rawBody, sharedSecret) and hex-encode it.
  3. Constant-time compare "sha256=" + hex against the header. If it does not match, return 401.
  4. Replay protection: reject the request if now - X-FML-Timestamp > 5 minutes (or whichever window your security team prefers). The body's timestamp field carries the same value and is covered by the HMAC, so an attacker cannot tamper with it without invalidating the signature.
CriticalVerify against the raw request bytes, not the parsed object. JSON serialisers reorder keys and renormalise whitespace, which will break the HMAC. In Express, use 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

  1. Create a Custom Setting called FmlWebhookConfig__c with a single text field Secret__c. Paste the secret you got from First Mile Labs into the org-default record.
  2. 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).
  3. 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:

  1. In the analyst console, create a new test case (or open an existing one).
  2. Take an action that emits an event — e.g. add a document to fire document.uploaded, or approve the case to fire case.decision.made.
  3. 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.
  4. 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.

IdempotencyBecause of automatic retries and manual redelivery, your receiver must be idempotent. The supplied dedup key is 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 returnsWe treat it asThen
2xx within 10 sDeliveredMark the delivery delivered. No further attempts.
4xx (any)FailureRetry 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)FailureRetry on the schedule above.
No response in 10 sFailure (timeout)Retry on the schedule above. Logged with HTTP 0.
Network error / DNS failureFailureRetry 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 minutes as the first step in your recipe.
  • Versioning: branch recipes on X-FML-Event-Version. Today it is 1; if it ever becomes 2, your 1 branch keeps running unchanged until you migrate.
  • Acknowledgement: respond 2xx within 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 5xx when 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.approved retains 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

SymptomLikely 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.