> For the complete documentation index, see [llms.txt](https://integrate.lexamica.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://integrate.lexamica.com/example-integrations/3a.-originator-full-webhook.md).

# Originator: Full Integration with Webhooks

Send cases and receive real-time updates on the full invitation lifecycle and status.

***

## 🎯 Overview

> **📌 TL;DR**
>
> Send cases to Lexamica and get notified at every stage of the invitation lifecycle — sent, evaluated, accepted, declined, expired, and more — plus status updates and file uploads. Set up your mappings with one call (recommended) or full manual control, then track mapped items so retried sends never create duplicate cases and incoming events get attributed to the right CRM record.

**This guide is for you if:**

* You send referrals to other firms via Lexamica
* You want real-time notifications across the full invitation lifecycle, not just accept/decline
* You need to keep your CRM or system in sync with Lexamica — without duplicate cases or duplicate status writes

**What you'll build:**

* Mappings for Case, CaseInvitation, CaseUpdate, and Attachment data — via a single Quick Setup call (recommended) or full manual control over field names
* A `sendCase` flow that checks your own mapped-item store before creating a case, so retries don't create duplicates
* Webhook subscriptions covering the full canonical invitation lifecycle and update/settlement events
* A mapped-item store that attributes incoming events to the right CRM record
* Multi-event webhook handler
* Optional: pushing cancellations and notes into Lexamica
* File sync in both directions, using the same mapped-item pattern to avoid duplicate uploads

**What this does NOT cover:**

* Receiving cases from other firms
* Responding to invitations (you're the sender, not receiver)
* Deep relay-matching visibility (see the [Advanced (Optional): Relay Visibility](#-advanced-optional-relay-visibility) appendix)

***

## 📖 Key Terms

| Term               | Definition                                                                                                                                                                                                    |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Originator**     | The firm that creates and sends out a case for referral                                                                                                                                                       |
| **Relay Engine**   | Lexamica's automatic matching system that finds partner firms                                                                                                                                                 |
| **Handler**        | The firm that accepts and handles the referred case                                                                                                                                                           |
| **CaseInvitation** | The object tracking one firm's invitation to handle a case, from evaluation through a final outcome                                                                                                           |
| **Quick Setup**    | A single endpoint call that creates all your mappings with Lexamica's standard field names — the same setup used for every integration Lexamica builds internally                                             |
| **Mapped Item**    | Your own record linking a `lexamica_case_id`-style ID to your CRM's foreign ID. Its presence is what lets an inbound event be attributed to a specific CRM case; without it, there's nothing to update        |
| **Direction**      | Whether a case is being referred out (`refer`) or received to handle (`acquire`). This guide covers `refer` only                                                                                              |
| **Custom Fields**  | A free-form bag of fields you define on a Case, echoed back on webhooks so you can carry your own data (like a correlation ID) through Lexamica. Not included by Quick Setup — able to add it after if needed |
| **Attachment**     | A file associated with a case, synced in either direction (Step 7)                                                                                                                                            |

***

## ⚙️ Architecture

```
Full Originator Integration (Webhooks)
──────────────────────────────────────

          OUTBOUND                                  INBOUND
          (You send cases)                          (Lexamica notifies you)

┌──────────────────────┐                    ┌───────────────────────┐
│      Your System      │                    │      Your System       │
│                        │                    │                        │
│ 1. Check mapped-item   │                    │    Webhook Handler     │
│    store (dedup)       │                    │                        │
│ 2. Send case via API   │                    │ • Invitation lifecycle │
│ 3. Save mapped item    │                    │   events               │
│    (your foreign ID    │                    │ • Update/Settlement    │
│    ↔ LexamicaId)       │                    │   events               │
│                        │                    │ • Relay events         │
│                        │                    │   (optional — see      │
│                        │                    │   Advanced appendix)   │
└───────────┬────────────┘                    └───────────▲────────────┘
            │                                              │
            │ POST                                         │ POST
            ▼                                              │
┌──────────────────────────────────────────────────────────────────────┐
│                              LEXAMICA                                │
│                                                                        │
│  ┌────────────┐     ┌─────────────┐     ┌────────────────────────┐   │
│  │    Case    │────▶│    Relay    │────▶│     CaseInvitation      │   │
│  │  Created   │     │   Engine    │     │         Sent            │   │
│  └────────────┘     │ (optional   │     └────────────┬────────────┘   │
│                      │ visibility) │                  │                │
│                      └─────────────┘                  ▼                │
│                                       Evaluated → Contact Attempted →  │
│                                       Consult Complete → Accepted /    │
│                                       Declined / Expired / Cancelled   │
│                                       → Closed                         │
│                                                        │                │
│                                                        ▼                │
│                              Webhook Delivery ─────────────────────────┼──▶
└────────────────────────────────────────────────────────────────────────┘
```

Files move in both directions too — see [Step 7: Sync Files](#step-7-sync-files).

***

## 📋 Prerequisites

**Credentials:**

* [ ] Organization ID, Public Key, and Private Key from Lexamica

**Infrastructure:**

* [ ] A publicly accessible HTTPS endpoint for webhooks

**Foundational Docs:**

* [ ] [Organizations and Authentication](/1.-organizations-and-authentication.md) — understand your API keys
* [ ] [Mapping Engine](/2.-mapping-engine.md) — how field transformations work
* [ ] [Inbound Webhooks](/3.-inbound-webhooks.md) — sending data to Lexamica
* [ ] [Webhook Subscriptions](/4.-webhook-subscriptions.md) — receiving events from Lexamica
* [ ] [File Operations](/example-integrations/apx-1.-file-operations.md) — needed for Step 7

***

## 💡 Step-by-Step Implementation

> **ℹ️ Heads up:** from Step 2 onward, every field name shown (`AssociatedCaseId`, `UpdateTitle`, `InvitedFirm.Name`, etc.) is what **Quick Setup** produces — the same field names used across every integration Lexamica runs internally. If you chose **Full Custom Setup** in Step 1 instead, the endpoints, events, and patterns are identical; just substitute whatever field names you configured for the ones shown.

### Step 1: Create Mappings

You have two options here. Pick one — don't mix them for the same model.

#### Quick Setup (recommended)

One call creates every mapping you need, using Lexamica's standard field names — the same setup used for every integration built internally.

```bash
curl -X POST \
  "https://integration.lexamica.com/organization/{orgId}/setup/default-mappings" \
  -H "Authorization: Bearer your_private_key"
```

No request body. It's idempotent — safe to call more than once. Any model that already has a mapping is skipped, not duplicated.

**Response:**

```json
{
  "success": true,
  "data": {
    "created": [
      { "_id": "665f1a...c01", "label": "Default Case Mapping", "modelName": "Case", "fieldMappings": [ "...45 fields, see reference below" ] },
      { "_id": "665f1a...c02", "label": "Default Case Invitation Mapping", "modelName": "CaseInvitation", "fieldMappings": [ "...14 fields" ] },
      { "_id": "665f1a...c03", "label": "Default Case Update Mapping", "modelName": "CaseUpdate", "fieldMappings": [ "...10 fields" ] },
      { "_id": "665f1a...c04", "label": "Default Inbound Case Update Mapping", "modelName": "CaseUpdate", "fieldMappings": [ "...7 fields" ] },
      { "_id": "665f1a...c05", "label": "Default Attachment Mapping", "modelName": "Attachment", "fieldMappings": [ "...10 fields" ] },
      { "_id": "665f1a...c06", "label": "Default Law Firm Mapping", "modelName": "LawFirm", "fieldMappings": [ "..." ] },
      { "_id": "665f1a...c07", "label": "Default Law Firm User Mapping", "modelName": "LawFirmUser", "fieldMappings": [ "..." ] }
    ],
    "skipped": []
  }
}
```

> **💡 Why two `CaseUpdate` mappings:** the **outbound** one (`Default Case Update Mapping`) is what transforms the `Case Update Made` webhook payload — it includes `Stage`/`WasUpdateOverdue`. The **inbound** one (`Default Inbound Case Update Mapping`) is what the optional "push a note" call in Step 4 uses — a smaller field set, since you're not expected to set those on the way in. Grab each mapping's `_id` by matching on `label`, not just `modelName` — this is the one model that appears twice.

> **ℹ️ Also created, not used in this guide:** `LawFirm` and `LawFirmUser` mappings. They support other integration patterns — safe to ignore here.

You'll notice **`customFields` isn't part of any default mapping.** If you want the correlation-field pattern from Step 5 (recommended for attribution), add it yourself after Quick Setup, via the mapping update endpoint:

```bash
curl -X POST \
  "https://integration.lexamica.com/organization/{orgId}/mapping/{caseMappingId}/update" \
  -H "Authorization: Bearer your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "fieldMappings": [
      { "lexamicaField": "customFields.crm_id", "foreignField": "crm_id", "lexamicaFieldType": "String", "foreignFieldType": "String" }
    ]
  }'
```

**Reference — the fields this guide actually uses**, from Quick Setup's defaults:

| Model                 | Field you'll see                                                                                   | What it is                                                             |
| --------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| Case                  | `LexamicaId`                                                                                       | The case's own ID (every model's own ID maps to `LexamicaId`)          |
| Case                  | `FirstName` / `LastName` / `Email` / `Phone`                                                       | Client contact info                                                    |
| Case                  | `IncidentDate` / `Summary`                                                                         | Incident date / synopsis                                               |
| Case                  | `IncidentAddressState`                                                                             | Incident state (required for sending a case)                           |
| Case                  | `PracticeArea`                                                                                     | Case type                                                              |
| Case                  | `CaseStatus` / `CaseStatusCycle`                                                                   | Current stage label / cycle                                            |
| Case                  | `TotalRecovery` / `NetRecovery` / `CaseReferralFee` / `RecoveryNotes`                              | Settlement figures                                                     |
| CaseInvitation        | `AssociatedCaseId`                                                                                 | **The case this invitation is for** — note the name                    |
| CaseInvitation        | `AcceptedDate` / `DeclinedDate` / `EvaluatedDate` / `ExpiredDate` / `CancelledDate` / `ClosedDate` | Per-outcome timestamps                                                 |
| CaseInvitation        | `DeclineReason`                                                                                    | Why it was declined                                                    |
| CaseInvitation        | `InvitedFirm.Name` / `InvitedFirm.LexamicaId`                                                      | The handler firm, nested (not a flat ID)                               |
| CaseUpdate (outbound) | `LexamicaCaseId`                                                                                   | **The case this update is for** — different name than CaseInvitation's |
| CaseUpdate (outbound) | `UpdateTitle` / `UpdateContent` / `UpdateType` / `WasUpdateOverdue`                                | Update content and metadata                                            |
| CaseUpdate (inbound)  | `LexamicaCaseId` / `UpdateTitle` / `UpdateContent` / `UpdateType`                                  | Same, minus `Stage`/`WasUpdateOverdue`                                 |
| Attachment            | `AssociatedCaseId`                                                                                 | The case this file belongs to                                          |
| Attachment            | `Name` / `Size` / `Mimetype` / `Url`                                                               | File metadata                                                          |

> **⚠️ `AssociatedCaseId` vs `LexamicaCaseId`:** the field that tells you which case an event belongs to is named differently depending on the model — `AssociatedCaseId` on `CaseInvitation` and `Attachment`, `LexamicaCaseId` on `CaseUpdate`. That's Lexamica's own default naming, not a typo — your handler needs to check the right one per event type. See Step 6.

#### Full Custom Setup (alternative)

If you'd rather choose every field name yourself — to match your CRM's existing property names exactly — create each mapping manually instead.

**Case Mapping:**

```bash
curl -X POST \
  "https://integration.lexamica.com/organization/{orgId}/mapping/create" \
  -H "Authorization: Bearer your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "Case Mapping",
    "modelName": "Case",
    "fieldMappings": [
      { "lexamicaField": "_id", "foreignField": "lexamica_case_id", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "client.firstName", "foreignField": "client_first_name", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "client.lastName", "foreignField": "client_last_name", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "client.email", "foreignField": "client_email", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "client.phoneNumber", "foreignField": "client_phone", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "caseType", "foreignField": "practice_area", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "incident.date", "foreignField": "incident_date", "lexamicaFieldType": "Date", "foreignFieldType": "String" },
      { "lexamicaField": "incident.address.state", "foreignField": "incident_state", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "incident.synopsis", "foreignField": "description", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "customFields.crm_id", "foreignField": "crm_id", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "customFields.direction", "foreignField": "direction", "lexamicaFieldType": "String", "foreignFieldType": "String" }
    ]
  }'
```

**CaseInvitation Mapping:**

```bash
curl -X POST \
  "https://integration.lexamica.com/organization/{orgId}/mapping/create" \
  -H "Authorization: Bearer your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "Case Invitation Mapping",
    "modelName": "CaseInvitation",
    "fieldMappings": [
      { "lexamicaField": "_id", "foreignField": "invitation_id", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "referral._id", "foreignField": "case_id", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "invitee._id", "foreignField": "handler_firm_id", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "accepted", "foreignField": "accepted_at", "lexamicaFieldType": "Date", "foreignFieldType": "String" },
      { "lexamicaField": "declined", "foreignField": "declined_at", "lexamicaFieldType": "Date", "foreignFieldType": "String" },
      { "lexamicaField": "declineReason", "foreignField": "decline_reason", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "referral.customFields.crm_id", "foreignField": "crm_id", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "referral.customFields.direction", "foreignField": "direction", "lexamicaFieldType": "String", "foreignFieldType": "String" }
    ]
  }'
```

**CaseUpdate Mapping:**

`CaseUpdate` is its own model, separate from `Case`. It reaches its parent case via `referral._id`, the same way `CaseInvitation` does.

```bash
curl -X POST \
  "https://integration.lexamica.com/organization/{orgId}/mapping/create" \
  -H "Authorization: Bearer your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "Case Update Mapping",
    "modelName": "CaseUpdate",
    "fieldMappings": [
      { "lexamicaField": "_id", "foreignField": "update_id", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "referral._id", "foreignField": "case_id", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "title", "foreignField": "update_title", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "content", "foreignField": "update_content", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "overdue", "foreignField": "is_overdue", "lexamicaFieldType": "Boolean", "foreignFieldType": "Boolean" },
      { "lexamicaField": "type", "foreignField": "update_type", "lexamicaFieldType": "String", "foreignFieldType": "String" }
    ]
  }'
```

Custom Setup can use one `CaseUpdate` mapping for both directions (unlike Quick Setup's two). Attachment Mapping is shown in [Step 7](#step-7-sync-files).

***

### Step 2: Create Webhook Subscriptions

Subscribe to the events you need to track. This guide's **canonical set is the invitation lifecycle plus update/settlement events** — that's what most integrations need. Relay events exist but are optional; see the [Advanced appendix](#-advanced-optional-relay-visibility) if you want them.

**Invitation Events:**

The full canonical set has nine events. Each uses the same call — only `event` and, for the terminal ones, the description change:

```bash
# Invitation sent
curl -X POST \
  "https://integration.lexamica.com/organization/{orgId}/webhook-subscriptions/create" \
  -H "Authorization: Bearer your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "Case Invitation Sent",
    "url": "https://your-system.com/webhooks/lexamica",
    "secret": "your_webhook_secret",
    "mapping": "your_invitation_mapping_id",
    "description": "Invitation sent to a firm",
    "active": true
  }'

# Invitation accepted
curl -X POST \
  "https://integration.lexamica.com/organization/{orgId}/webhook-subscriptions/create" \
  -H "Authorization: Bearer your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "Case Invitation Accepted",
    "url": "https://your-system.com/webhooks/lexamica",
    "secret": "your_webhook_secret",
    "mapping": "your_invitation_mapping_id",
    "description": "Partner accepted case",
    "active": true
  }'
```

`your_invitation_mapping_id` is the `_id` of `Default Case Invitation Mapping` (Quick Setup) or your own CaseInvitation mapping (Custom Setup). Repeat the same call for the rest of the set, changing only `"event"`:

| Event                                         | What it means                               | Typically triggers                        |
| --------------------------------------------- | ------------------------------------------- | ----------------------------------------- |
| `Case Invitation Sent`                        | Invitation went out to a firm               | Informational — no action required        |
| `Case Invitation Evaluated`                   | Firm marked it "under evaluation"           | Optional: surface "under review" to staff |
| `Case Invitation Evaluated Contact Attempted` | Firm attempted to contact the client        | Optional: log the attempt                 |
| `Case Invitation Evaluated Consult Complete`  | Firm completed a consultation               | Optional: log the consult                 |
| `Case Invitation Accepted`                    | Firm accepted (terminal)                    | Update status, store handler info         |
| `Case Invitation Declined`                    | Firm declined (terminal)                    | Log reason, wait for other partners       |
| `Case Invitation Expired`                     | Invitation passed its expiration (terminal) | Alert staff, may need re-routing          |
| `Case Invitation Cancelled`                   | You cancelled it (terminal)                 | Sync cancellation, stop waiting           |
| `Case Invitation Closed`                      | Invitation closed out                       | Finalize local record                     |

> **💡 Why this matters:** if you only subscribe to `Accepted`/`Declined`, you have no visibility into invitations stuck "evaluating" for days, or invitations that quietly expired — two of the most common support-ticket-generating blind spots in a referral integration.

> **ℹ️ Under Quick Setup, `Contact Attempted` and `Consult Complete` carry no dedicated timestamp field** — the default `CaseInvitation` mapping doesn't map one. You'll still get the event (and the common fields: `LexamicaId`, `DateCreated`, `DateUpdated`, `AssociatedCaseId`) — just log it as "this stage happened," not "at this specific time."

**Update and Settlement Events:**

```bash
# Status updates
curl -X POST \
  "https://integration.lexamica.com/organization/{orgId}/webhook-subscriptions/create" \
  -H "Authorization: Bearer your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "Case Update Made",
    "url": "https://your-system.com/webhooks/lexamica",
    "secret": "your_webhook_secret",
    "mapping": "your_case_update_mapping_id",
    "description": "Status update posted",
    "active": true
  }'

# Settlement
curl -X POST \
  "https://integration.lexamica.com/organization/{orgId}/webhook-subscriptions/create" \
  -H "Authorization: Bearer your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "Case Settlement",
    "url": "https://your-system.com/webhooks/lexamica",
    "secret": "your_webhook_secret",
    "mapping": "your_case_mapping_id",
    "description": "Case settlement started",
    "active": true
  }'
```

`your_case_update_mapping_id` here is the **outbound** `Default Case Update Mapping` (the one with `Stage`/`WasUpdateOverdue`) — not the inbound one from Step 4. `Case Update Made` fires whenever anyone posts a note or status update on the case, including your own calls from Step 4.

> **💡 What to expect from `Case Settlement`:** under Quick Setup, the payload includes real settlement figures automatically — `TotalRecovery`, `NetRecovery`, `CaseReferralFee`, `CaseStatus`, `CaseStatusCycle` (values like `closed.final.withFee` / `closed.final.withNoFee`), `RecoveryNotes`, plus all the standard client/incident fields since this uses the Case mapping. The common pattern — shown in the `Case Settlement` handler in the Complete Code Example — is to render the settlement-relevant fields into a single note rather than modeling each one as a structured status field.

***

### Step 3: Send Cases

Send cases using your Public Key (same as [Lead Form Submission](https://github.com/Lexamica/lexamica-docs/tree/main/services/universal-integration/Example%20Integrations/lead-form-submission.md)).

> **💡 Why this matters:** whatever triggers `sendCase()` on your end — a status change, a retried job, a user action — firing twice for the same CRM record calls Lexamica's create-case endpoint twice. Lexamica has no way to know these two calls represent the same case; it will create two separate ones. The fix is a lookup before you create: if a mapped item already exists for this CRM record, it's already in Lexamica — return it. If not, this case is new and not yet in Lexamica — create it.

```javascript
async function sendCase(foreignId, caseData) {
  const existing = await findMappedItemByForeignId(foreignId);
  if (existing) {
    // Already in Lexamica — return the existing mapping instead of
    // creating a duplicate case.
    return existing;
  }

  // No mapped item found: this case is new, not yet in Lexamica.
  const response = await fetch(
    `${BASE_URL}/organization/${ORG_ID}/inbound-webhooks/case/${CASE_MAPPING_ID}/send?Key=${PUBLIC_KEY}`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(caseData)
    }
  );

  const result = await response.json();

  // Record the mapped item so future sends can find this case by your
  // foreign ID, and incoming webhooks can attribute events back to it.
  await saveMappedItem(foreignId, result.LexamicaId);

  return result;
}

// Usage — required fields per 3. inbound-webhooks.md: FirstName, LastName,
// Phone, PracticeArea, IncidentDate, IncidentAddressState, Summary.
await sendCase('crm-12345', {
  FirstName: 'Jane',
  LastName: 'Smith',
  Phone: '555-123-4567',
  PracticeArea: 'Personal Injury',
  IncidentDate: '2026-01-10',
  IncidentAddressState: 'CA',
  Summary: 'Rear-end collision on Highway 101.'
});
```

`foreignId` is entirely yours — it's how *you* identify this case locally, kept separate from whatever fields Lexamica needs. Quick Setup doesn't echo it back to you (no `customFields` by default — see Step 1), so hang onto it yourself; that's what `saveMappedItem` is for.

***

### Step 4: Cancel and Update Cases (Optional)

These two operations are optional — nothing else in this guide depends on them, and skipping either doesn't break the invitation lifecycle or update/settlement webhooks from the steps above. Add them if your CRM needs to push cancellations or notes into Lexamica.

**Cancel an invitation you sent:**

```bash
curl -X POST \
  "https://integration.lexamica.com/organization/{orgId}/inbound-webhooks/case-invitation/{mapId}/cancel?Key=your_public_key" \
  -H "Content-Type: application/json" \
  -d '{
    "LexamicaId": "64f1a2b3c4d5e6f7a8b9c0d4"
  }'
```

`{mapId}` is the CaseInvitation mapping's ID. `LexamicaId` here is the *invitation's* own ID (every model's own `_id` maps to `LexamicaId` under Quick Setup) — not the case's.

> **💡 Why you might want this:** if the underlying CRM record is deleted or reassigned before a firm responds, canceling keeps Lexamica in sync instead of leaving a stale invitation pending. You get the invitation's `LexamicaId` back on `Case Invitation Sent` (Step 2), and there's no way to look it up after the fact. If you plan to use Cancel, hold onto that ID somewhere when the invitation goes out — a field on the case's mapped item works fine, or wherever else fits your system. See [Step 5](#step-5-track-mapped-items-idempotency) for one way to do it.

**Push a note or status update onto a case:**

```bash
curl -X POST \
  "https://integration.lexamica.com/organization/{orgId}/inbound-webhooks/case/{mapId}/update?Key=your_public_key" \
  -H "Content-Type: application/json" \
  -d '{
    "LexamicaCaseId": "64f1a2b3c4d5e6f7a8b9c0d3",
    "UpdateTitle": "Client contacted",
    "UpdateContent": "Called to confirm availability for an initial consult.",
    "UpdateType": "note"
  }'
```

`{mapId}` here is the **inbound** `Default Inbound Case Update Mapping`'s ID (Quick Setup) — a different mapping than the one Step 2's `Case Update Made` subscription uses.

> **💡 Why you might want this:** lets internal CRM notes show up on the Lexamica case timeline, visible to the handler firm. Use `UpdateType: "status"` for a required periodic status update rather than a general note. This is the reverse direction of the `Case Update Made` webhook (Step 2), which notifies *you* when someone else posts an update — this call is you posting one.

***

### Step 5: Track Mapped Items (Idempotency)

The check in Step 3 solves one concrete problem: **preventing duplicate case creation.** Whatever triggers `sendCase()` on your end firing twice for the same CRM record means calling Lexamica's create-case endpoint twice — Lexamica doesn't dedupe this for you, so two calls create two separate cases. The rule is simple: `if (mapped item exists) return existing; else create()` — this case is new and not currently in Lexamica.

The same mapped item also gates the inbound side, but purely for **attribution** — not per-event dedup. When a webhook event arrives, the question is simply: does a mapped item exist for this case?

* **No mapped item** — you have no CRM record to attribute this event to. Ignore it.
* **Mapped item exists** — sync the update. Apply what the payload says; don't try to track which specific events you've already applied.

Under Quick Setup, there's no `customFields` echo by default (Step 1), so **your own persisted record is the primary way to resolve attribution** — a table linking `LexamicaId` (the case's own ID) to your foreign ID, populated by `saveMappedItem()` when you called `sendCase()` in Step 3. If you added `customFields.crm_id` yourself (Step 1's optional extra call), that gets echoed back too and can serve as a secondary signal — but it isn't there unless you added it.

**There is no Lexamica endpoint to look up a case by your foreign ID** — the resolution happens on your side either way. This is the same mapped-item pattern already used in [1. lead-form-submission.md](/example-integrations/1.-lead-form-submission.md) and [Import Existing Cases with Webhooks](/example-integrations/5a.-import-existing-webhook.md) — and, in Step 7, for files.

A minimal mapped-item record looks like:

```json
{
  "foreignId": "crm-12345",
  "lexamicaCaseId": "64f1a2b3c4d5e6f7a8b9c0d3",
  "updatedAt": "2026-07-06T14:22:00.000Z"
}
```

```javascript
// Lookup before sending a new case (Step 3) — prevents duplicate case creation
async function findMappedItemByForeignId(foreignId) {
  return db.mappedItems.findOne({ foreignId });
}

// Lookup on an incoming webhook (Step 6) — used for attribution
async function findMappedItemByCaseId(lexamicaCaseId) {
  return db.mappedItems.findOne({ lexamicaCaseId });
}

async function saveMappedItem(foreignId, lexamicaCaseId) {
  await db.mappedItems.updateOne(
    { foreignId },
    { $set: { foreignId, lexamicaCaseId, updatedAt: new Date() } },
    { upsert: true }
  );
}
```

> **⚠️ Which payload field holds the case ID depends on the event.** Under Quick Setup: `AssociatedCaseId` on `CaseInvitation`/`Attachment` events, `LexamicaCaseId` on `CaseUpdate` events, `LexamicaId` on `Case`-level events (`Case Settlement`). Resolve whichever applies before calling `findMappedItemByCaseId` — see Step 6's handler for exactly how.

> **💡 Planning to use Cancel (Step 4)?** You'll need the invitation's `LexamicaId` later, and there's no Lexamica endpoint to look it up after the fact — so capture it when `Case Invitation Sent` fires, not when you decide to cancel. Attaching it to the same mapped item is one easy option, shown below, but it doesn't have to live there — a separate table, a field on your own case record, whatever fits how you're already storing things.

```javascript
// Optional — only relevant if you're using Cancel (Step 4).
async function recordInvitationId(lexamicaCaseId, invitationId) {
  await db.mappedItems.updateOne(
    { lexamicaCaseId },
    { $set: { lexamicaInvitationId: invitationId } }
  );
}
```

In the webhook handler (Step 6), resolve the case ID from the right field for the event type, look up the mapped item, and ignore the event if it doesn't resolve. If it resolves, sync the update.

***

### Step 6: Handle Webhooks

Build a handler that resolves attribution before applying an event.

***

### Step 7: Sync Files

Files move in both directions, and — like cases — need a mapped-item check to avoid duplicates. Use a **separate** mapped-item store for attachments (keyed by attachment ID, not case ID), so file dedup doesn't collide with the case/invitation tracking from Step 5.

**Attachment Mapping:** already created if you used Quick Setup (`Default Attachment Mapping`, Step 1). For Custom Setup, create it manually:

```bash
curl -X POST \
  "https://integration.lexamica.com/organization/{orgId}/mapping/create" \
  -H "Authorization: Bearer your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "Attachment Mapping",
    "modelName": "Attachment",
    "fieldMappings": [
      { "lexamicaField": "_id", "foreignField": "attachment_id", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "referral._id", "foreignField": "case_id", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "filename", "foreignField": "filename", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "mimetype", "foreignField": "mimetype", "lexamicaFieldType": "String", "foreignFieldType": "String" },
      { "lexamicaField": "size", "foreignField": "size", "lexamicaFieldType": "Number", "foreignFieldType": "Number" },
      { "lexamicaField": "url", "foreignField": "download_url", "lexamicaFieldType": "String", "foreignFieldType": "String" }
    ]
  }'
```

**Sending files to Lexamica (inbound):** check for an existing mapped item before uploading — same principle as Step 3, applied to files instead of cases. Pick `Direct Upload` for files you already have locally, or `Stream Upload` when the file is already hosted elsewhere (S3, GCS) and you'd rather Lexamica fetch it than route it through your own server. The request parameters here (`caseId`, `file`, `fileName`, `url`, `filename`, `mimetype`, `size`) are fixed by the endpoint itself, not affected by which mapping setup you chose — only the *response* and the outbound webhook payload go through the mapping.

```javascript
// Direct Upload — smaller files you already have on disk
async function syncFileToLexamica(caseId, foreignFileId, filePath, fileName) {
  const existing = await findAttachmentMappedItemByForeignId(foreignFileId);
  if (existing) {
    return existing; // already uploaded — don't create a duplicate attachment
  }

  const form = new FormData();
  form.append('caseId', caseId);
  form.append('file', fs.createReadStream(filePath));
  form.append('fileName', fileName);

  const response = await axios.post(
    `${BASE_URL}/organization/${ORG_ID}/inbound-webhooks/case/${ATTACHMENT_MAPPING_ID}/file/upload`,
    form,
    { params: { Key: PUBLIC_KEY }, headers: form.getHeaders() }
  );

  await saveAttachmentMappedItem(foreignFileId, response.data.LexamicaId);
  return response.data;
}

// Stream Upload — file already hosted elsewhere; Lexamica fetches it
async function streamFileToLexamica(caseId, foreignFileId, fileUrl, filename, mimetype, size) {
  const existing = await findAttachmentMappedItemByForeignId(foreignFileId);
  if (existing) {
    return existing;
  }

  const response = await axios.post(
    `${BASE_URL}/organization/${ORG_ID}/inbound-webhooks/case/${ATTACHMENT_MAPPING_ID}/file/stream`,
    { caseId, url: fileUrl, filename, mimetype, size },
    { params: { Key: PUBLIC_KEY }, headers: { 'Content-Type': 'application/json' } }
  );

  await saveAttachmentMappedItem(foreignFileId, response.data.LexamicaId);
  return response.data;
}
```

> **💡 Why this matters:** the same duplicate-creation risk from Step 3 applies here — if whatever triggers a file sync (a new attachment event on your side, a retried job) fires twice for the same file, you'll upload it twice without this check. See [File Operations](/example-integrations/apx-1.-file-operations.md) for size limits and the Remove File operation.

**Receiving files from Lexamica (outbound):** subscribe to `Case File Uploaded`.

```bash
curl -X POST \
  "https://integration.lexamica.com/organization/{orgId}/webhook-subscriptions/create" \
  -H "Authorization: Bearer your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "Case File Uploaded",
    "url": "https://your-system.com/webhooks/lexamica",
    "secret": "your_webhook_secret",
    "mapping": "your_attachment_mapping_id",
    "description": "File upload notifications",
    "active": true
  }'
```

> **💡 Why this matters — act on it immediately:** the `Url` in this payload is a short-lived signed URL to Lexamica's file storage that typically expires after a few hours. Download the file and attribute it to the right CRM record within the same handler call — don't just store the URL for later, or it may already be dead by the time you get to it.

```javascript
async function handleFileUploaded(payload) {
  const existing = await findAttachmentMappedItemByAttachmentId(payload.LexamicaId);
  if (existing) {
    return; // already processed this attachment
  }

  // Attribution: which CRM case does this file belong to? Reuse the case
  // mapped item from Step 5 — the file event itself doesn't carry your
  // correlation field.
  const caseMappedItem = await findMappedItemByCaseId(payload.AssociatedCaseId);
  if (!caseMappedItem) {
    console.log(`Can't attribute file on case ${payload.AssociatedCaseId} — ignoring`);
    return;
  }

  // Download now — the signed URL expires in a few hours.
  await downloadFile(payload.Url, `./downloads/${payload.Name}`);
  const localFileId = await storeFileInCrm(caseMappedItem.foreignId, payload.Name);

  await saveAttachmentMappedItem(localFileId, payload.LexamicaId);
}

async function downloadFile(fileUrl, savePath) {
  const response = await axios.get(fileUrl, { responseType: 'stream' });
  const writer = fs.createWriteStream(savePath);
  response.data.pipe(writer);
  return new Promise((resolve, reject) => {
    writer.on('finish', resolve);
    writer.on('error', reject);
  });
}

// Lookup before uploading (Step 7, inbound) — dedup on your side
async function findAttachmentMappedItemByForeignId(foreignFileId) {
  return db.attachmentMappedItems.findOne({ foreignFileId });
}

// Lookup on an incoming Case File Uploaded webhook — dedup
async function findAttachmentMappedItemByAttachmentId(attachmentId) {
  return db.attachmentMappedItems.findOne({ attachmentId });
}

async function saveAttachmentMappedItem(foreignFileId, attachmentId) {
  await db.attachmentMappedItems.updateOne(
    { foreignFileId },
    { $set: { foreignFileId, attachmentId, updatedAt: new Date() } },
    { upsert: true }
  );
}
```

Same two-part logic as the case flow, applied to files: a mapped item for dedup (have I already handled this specific attachment) and the case's mapped item for **attribution** (which CRM record does it belong to) — separate concerns, checked separately, same as Step 5.

***

## 🔧 Complete Code Example

```javascript
const express = require('express');
const crypto = require('crypto');

const app = express();

app.use(express.json({
  verify: (req, res, buf) => { req.rawBody = buf; }
}));

const WEBHOOK_SECRET = process.env.LEXAMICA_WEBHOOK_SECRET;

// Verify signature
function verifySignature(rawBody, signature) {
  if (!signature) return false;
  const expected = 'sha256=' + crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(rawBody)
    .digest('hex');
  try {
    return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  } catch { return false; }
}

// Event handlers — the canonical invitation lifecycle plus update/settlement
// events. Relay events (Case Relay Matched/Rejected/Missed/Stopped) are
// optional and not included here — see the Advanced appendix. Field names
// below are Quick Setup's defaults (Step 1) — substitute your own if you
// used Custom Setup.
const handlers = {
  // Invitation lifecycle
  'Case Invitation Sent': async (payload) => {
    await updateCase(payload.AssociatedCaseId, { status: 'invited' });
    // Only needed if you plan to use Cancel (Step 4) — there's no way to
    // look this up later, so capture it now.
    await recordInvitationId(payload.AssociatedCaseId, payload.LexamicaId);
  },

  'Case Invitation Evaluated': async (payload) => {
    await addCaseActivity(payload.AssociatedCaseId, { type: 'invitation_evaluating' });
  },

  'Case Invitation Evaluated Contact Attempted': async (payload) => {
    // No dedicated timestamp field under Quick Setup — see Step 2.
    await addCaseActivity(payload.AssociatedCaseId, { type: 'invitation_contact_attempted' });
  },

  'Case Invitation Evaluated Consult Complete': async (payload) => {
    await addCaseActivity(payload.AssociatedCaseId, { type: 'invitation_consult_complete' });
  },

  'Case Invitation Accepted': async (payload) => {
    console.log(`Case ${payload.AssociatedCaseId} accepted by ${payload.InvitedFirm?.Name}`);
    await updateCase(payload.AssociatedCaseId, {
      status: 'accepted',
      handlerFirmId: payload.InvitedFirm?.LexamicaId,
      handlerFirmName: payload.InvitedFirm?.Name,
      acceptedAt: payload.AcceptedDate
    });
  },

  'Case Invitation Declined': async (payload) => {
    console.log(`Case ${payload.AssociatedCaseId} declined: ${payload.DeclineReason}`);
    await updateCase(payload.AssociatedCaseId, { status: 'declined' });
    await addCaseActivity(payload.AssociatedCaseId, {
      type: 'invitation_declined',
      reason: payload.DeclineReason,
      date: payload.DeclinedDate
    });
  },

  'Case Invitation Expired': async (payload) => {
    await updateCase(payload.AssociatedCaseId, { status: 'expired' });
    await notifyStaff(`Invitation expired for case ${payload.AssociatedCaseId} — may need re-routing`);
  },

  'Case Invitation Cancelled': async (payload) => {
    await updateCase(payload.AssociatedCaseId, { status: 'cancelled' });
  },

  'Case Invitation Closed': async (payload) => {
    await updateCase(payload.AssociatedCaseId, { status: 'closed' });
  },

  // Update & settlement
  'Case Update Made': async (payload) => {
    console.log(`Update on case ${payload.LexamicaCaseId}: ${payload.UpdateTitle}`);
    await addCaseActivity(payload.LexamicaCaseId, {
      type: 'status_update',
      title: payload.UpdateTitle,
      content: payload.UpdateContent,
      overdue: payload.WasUpdateOverdue,
      date: new Date()
    });
  },

  'Case Settlement': async (payload) => {
    console.log(`Case ${payload.LexamicaId} entering settlement`);

    // These are Quick Setup's real default fields — no guessing required.
    const noteBody = [
      `Case Referral Fee: ${payload.CaseReferralFee}`,
      `Net Recovery: ${payload.NetRecovery}`,
      `Total Recovery: ${payload.TotalRecovery}`,
      `Closed Status: ${payload.CaseStatus}`,
      `Closing Notes: ${payload.RecoveryNotes || ''}`
    ].join('\n');

    await addCaseActivity(payload.LexamicaId, {
      type: 'settlement',
      title: 'Settlement Details',
      content: noteBody,
      date: new Date()
    });

    await updateCase(payload.LexamicaId, {
      status: 'settling',
      settlementStarted: new Date()
    });
  }

  // If you've subscribed to Case File Uploaded (Step 7), add
  // 'Case File Uploaded': handleFileUploaded here — it depends on functions
  // defined in that step (findAttachmentMappedItemByAttachmentId, axios,
  // fs) that aren't declared in this snippet.
};

// Main webhook endpoint
app.post('/webhooks/lexamica', async (req, res) => {
  const signature = req.headers['x-lexamica-signature'];
  const eventType = req.headers['x-lexamica-event'];

  if (!verifySignature(req.rawBody, signature)) {
    return res.status(401).send('Invalid signature');
  }

  // Respond immediately
  res.status(200).send('OK');

  // Process asynchronously
  const handler = handlers[eventType];
  if (!handler) {
    console.log(`Unhandled event: ${eventType}`);
    return;
  }

  try {
    // The case-reference field differs by model — see Step 5's warning.
    const caseId = req.body.AssociatedCaseId || req.body.LexamicaCaseId || req.body.LexamicaId;
    const mappedItem = await findMappedItemByCaseId(caseId);

    // Attribution — see Step 5. Either your own mapped item resolves this,
    // or (only if you added it yourself in Step 1) a customFields value.
    const crmCaseId = mappedItem?.foreignId || req.body.crm_id;
    if (!crmCaseId) {
      console.log(`Can't attribute case ${caseId} to a CRM record — ignoring ${eventType}`);
      return;
    }

    await handler(req.body);
    console.log(`Processed: ${eventType}`);
  } catch (error) {
    console.error(`Failed to process ${eventType}:`, error);
  }
});

// Placeholder functions - implement for your system
async function updateCase(caseId, data) {
  console.log('Update case:', caseId, data);
}

async function addCaseActivity(caseId, activity) {
  console.log('Add activity:', caseId, activity);
}

async function notifyStaff(message) {
  console.log('Notify:', message);
}

app.listen(3000, () => console.log('Webhook server running'));
```

***

## 📋 Event Flow Reference

Field names below are Quick Setup's defaults.

| Event                                         | Case ID field      | Maps to Status      | Typical Action                                                                      |
| --------------------------------------------- | ------------------ | ------------------- | ----------------------------------------------------------------------------------- |
| `Case Invitation Sent`                        | `AssociatedCaseId` | `invited`           | Informational — no action required                                                  |
| `Case Invitation Evaluated`                   | `AssociatedCaseId` | `evaluating`        | Optional: surface "under review" to staff                                           |
| `Case Invitation Evaluated Contact Attempted` | `AssociatedCaseId` | `contact_attempted` | Optional: log the attempt (no timestamp field)                                      |
| `Case Invitation Evaluated Consult Complete`  | `AssociatedCaseId` | `consult_complete`  | Optional: log the consult (no timestamp field)                                      |
| `Case Invitation Accepted`                    | `AssociatedCaseId` | `accepted`          | Update status, store `InvitedFirm.Name`/`LexamicaId`                                |
| `Case Invitation Declined`                    | `AssociatedCaseId` | `declined`          | Log `DeclineReason`, wait for other partners                                        |
| `Case Invitation Expired`                     | `AssociatedCaseId` | `expired`           | Alert staff, may need re-routing                                                    |
| `Case Invitation Cancelled`                   | `AssociatedCaseId` | `cancelled`         | Sync cancellation, stop waiting                                                     |
| `Case Invitation Closed`                      | `AssociatedCaseId` | `closed`            | Finalize local record                                                               |
| `Case Update Made`                            | `LexamicaCaseId`   | (unchanged)         | Sync to your system                                                                 |
| `Case Settlement`                             | `LexamicaId`       | `settling`          | Render settlement fields into a note (see Complete Code Example)                    |
| `Case File Uploaded`                          | `AssociatedCaseId` | (n/a)               | Download immediately (short-lived URL) and attribute to the right case — see Step 7 |

***

## 🔧 Advanced (Optional): Relay Visibility

**Why you might want this:** relay events give you visibility into the matching stage — *before* an invitation is even sent to a firm. Useful if you want to alert staff the moment the Relay Engine can't find anyone to invite, rather than waiting for an invitation-level signal.

**Why most integrations skip it:** relay activity is transient and internal to the matching process. The invitation lifecycle events in Step 2 already tell you the outcome that actually matters (whether a firm was invited, and what happened next) — so most integrations get full operational visibility without subscribing to relay events at all.

| Event                 | Triggered When                          |
| --------------------- | --------------------------------------- |
| `Case Relay Matched`  | Relay Engine finds a matching partner   |
| `Case Relay Rejected` | Relay Engine can't find any matches     |
| `Case Relay Missed`   | Partners were invited but none accepted |
| `Case Relay Stopped`  | Relay matching process was stopped      |

**Subscribing:**

```bash
# Example: alert when no partners are available
curl -X POST \
  "https://integration.lexamica.com/organization/{orgId}/webhook-subscriptions/create" \
  -H "Authorization: Bearer your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "Case Relay Rejected",
    "url": "https://your-system.com/webhooks/lexamica",
    "secret": "your_webhook_secret",
    "mapping": "your_case_mapping_id",
    "description": "No partners available",
    "active": true
  }'
```

Repeat for `Case Relay Matched`, `Case Relay Missed`, or `Case Relay Stopped` as needed, changing only `"event"`.

**Optional handler snippet** (add to the `handlers` map in the Complete Code Example only if you've subscribed to these):

```javascript
'Case Relay Matched': async (payload) => {
  await updateCase(payload.LexamicaId, { status: 'matched', matchedAt: new Date() });
},

'Case Relay Rejected': async (payload) => {
  await updateCase(payload.LexamicaId, { status: 'no_match', rejectedAt: new Date() });
  await notifyStaff(`No partners found for case ${payload.LexamicaId}`);
},

'Case Relay Missed': async (payload) => {
  await updateCase(payload.LexamicaId, { status: 'all_declined', missedAt: new Date() });
},

'Case Relay Stopped': async (payload) => {
  await updateCase(payload.LexamicaId, { status: 'relay_stopped', stoppedAt: new Date() });
}
```

These are **not part of the canonical minimum-viable integration** — treat them as an add-on once the invitation + update flow is working.

***

## ❓ FAQ

### ❓ "Should I use Quick Setup or Custom Setup?"

**📝 Full Answer:** Quick Setup, unless you have a specific reason not to — it's the exact same mapping configuration used for every integration built internally, it's one API call, and it's idempotent (safe to re-run). Reach for Custom Setup only if you need field names that match your own CRM's existing property names exactly, or need fields Quick Setup's defaults don't cover.

***

### ❓ "Do I need all these subscriptions?"

**📝 Full Answer:** No. Start with what you need:

* **Minimum:** `Case Invitation Accepted` and `Case Invitation Declined` (know the terminal outcome for every case you send)
* **Recommended (canonical):** All nine invitation lifecycle events, plus `Case Update Made` and `Case Settlement` — full visibility into where every case is stuck, not just where it ended up
* **Advanced:** Add relay events from the [appendix above](#-advanced-optional-relay-visibility) if you specifically need matching-stage visibility. These are not part of the standard integration.

***

### ❓ "Do I need to implement Cancel, Update, or file sync?"

**📝 Full Answer:** No — Steps 4 and 7 are additive. The invitation lifecycle and update/settlement webhooks work whether or not you ever call Cancel, push a note, or sync a single file. Add them when your CRM actually needs to write back into Lexamica or move documents between the two systems, not before.

***

### ❓ "How do I correlate webhooks with my records?"

**📝 Full Answer:** Use the mapped-item pattern from [Step 5](#step-5-track-mapped-items-idempotency):

1. Maintain your own foreign-ID-to-`LexamicaId` table, populated when you call `sendCase()` (Step 3). This works regardless of which mapping setup you chose.
2. Optionally, add a `customFields` correlation field yourself (Step 1) so Lexamica echoes it back too — Quick Setup doesn't include one by default.
3. On every inbound webhook, resolve the case ID from whichever field the event uses (`AssociatedCaseId`, `LexamicaCaseId`, or `LexamicaId` — see Step 5), then look up your mapped item.
4. If it doesn't resolve, you have no CRM record to attribute the event to — ignore it.

***

## ➡️ Next Steps

* **Can't receive webhooks?** See [Originator: Full Integration with Polling](/example-integrations/3b.-originator-full-polling.md)
* **Need more file detail?** See [File Operations](/example-integrations/apx-1.-file-operations.md) for size limits, removing files, and polling for upload events
* **Importing existing cases?** See [Import Existing Cases with Webhooks](/example-integrations/5a.-import-existing-webhook.md)
* **Full event catalog:** See [Webhook Subscriptions](/4.-webhook-subscriptions.md)

***

*Last updated: July 2026*
