> 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/apx-1.-file-operations.md).

# File Operations

Upload, download, and manage case files through the API.

***

## 🎯 Overview

> **📌 TL;DR**
>
> Upload files directly or via URL, remove files when needed, and get notified when files are uploaded to cases you're involved with. This appendix works with any integration pattern.

**This guide covers:**

* Uploading files directly to cases
* Uploading files via URL (for larger files)
* Removing files from cases
* Receiving notifications when files are uploaded

**Use this with:**

* Any originator or handler integration
* Both webhook and polling patterns

***

## 📖 Key Terms

| Term              | Definition                                      |
| ----------------- | ----------------------------------------------- |
| **Attachment**    | A file associated with a case                   |
| **Direct Upload** | Upload file content directly via multipart form |
| **Stream Upload** | Upload file from a URL (Lexamica fetches it)    |

***

## 📋 Prerequisites

**Credentials:**

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

**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

**For file upload notifications (optional):**

* [ ] [Webhook Subscriptions](/4.-webhook-subscriptions.md) — if receiving notifications via webhooks
* [ ] [Event Storage and Polling](/5.-event-storage-polling.md) — if polling for notifications

***

## 💡 Uploading Files

> **💡 Avoid duplicate uploads:** before calling either upload method below, check your own persisted mapped-item store for an existing attachment mapping by your foreign file ID. If one exists, return it instead of uploading again — this matters if whatever triggers a sync (a retried job, a re-processed queue message) fires more than once for the same file. After a successful upload, save the mapping (your foreign file ID ↔ the returned `attachment_id`). See [Attachment Mapping](#-attachment-mapping) below for a sample mapped-item store using `findAttachmentMappedItemByForeignId` / `saveAttachmentMappedItem` — the same pattern used in [Originator: Full Integration with Webhooks](/example-integrations/3a.-originator-full-webhook.md)'s Step 7.

### Method 1: Direct Upload

Best for smaller files uploaded directly from your server.

**Endpoint:** `POST /organization/{orgId}/inbound-webhooks/case/{mapId}/file/upload`

**Request (multipart/form-data):**

```bash
curl -X POST \
  "https://integration.lexamica.com/organization/{orgId}/inbound-webhooks/case/{mapId}/file/upload?Key=your_public_key" \
  -F "caseId=64f1a2b3c4d5e6f7a8b9c0d3" \
  -F "file=@/path/to/document.pdf" \
  -F "fileName=medical_records.pdf"
```

**Response:**

```json
{
  "attachment_id": "64f1a2b3c4d5e6f7a8b9c0e1",
  "filename": "medical_records.pdf",
  "size": 245678,
  "mimetype": "application/pdf",
  "url": "https://storage.lexamica.com/files/64f1a2b3c4d5e6f7a8b9c0e1"
}
```

**Node.js Example:**

```javascript
const FormData = require('form-data');
const fs = require('fs');
const axios = require('axios');

async function uploadFile(caseId, filePath, fileName, foreignFileId) {
  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 || path.basename(filePath));
  
  const response = await axios.post(
    `${BASE_URL}/organization/${ORG_ID}/inbound-webhooks/case/${MAPPING_ID}/file/upload`,
    form,
    {
      params: { Key: PUBLIC_KEY },
      headers: form.getHeaders()
    }
  );
  
  await saveAttachmentMappedItem(foreignFileId, response.data.attachment_id);
  return response.data;
}

// Usage
const attachment = await uploadFile(
  '64f1a2b3c4d5e6f7a8b9c0d3',
  './documents/intake_form.pdf',
  'Client Intake Form.pdf',
  'crm-file-482'
);
```

### Method 2: Stream Upload (URL-based)

Best for larger files or files already hosted elsewhere. Lexamica fetches the file from the URL you provide.

**Endpoint:** `POST /organization/{orgId}/inbound-webhooks/case/{mapId}/file/stream`

**Request:**

```bash
curl -X POST \
  "https://integration.lexamica.com/organization/{orgId}/inbound-webhooks/case/{mapId}/file/stream?Key=your_public_key" \
  -H "Content-Type: application/json" \
  -d '{
    "caseId": "64f1a2b3c4d5e6f7a8b9c0d3",
    "url": "https://your-storage.com/files/document.pdf",
    "filename": "accident_photos.pdf",
    "mimetype": "application/pdf",
    "size": 1245678
  }'
```

**Node.js Example:**

```javascript
async function uploadFileFromUrl(caseId, fileUrl, filename, mimetype, size, foreignFileId) {
  const existing = await findAttachmentMappedItemByForeignId(foreignFileId);
  if (existing) {
    return existing; // already uploaded — don't create a duplicate attachment
  }

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

// Usage
const attachment = await uploadFileFromUrl(
  '64f1a2b3c4d5e6f7a8b9c0d3',
  'https://mybucket.s3.amazonaws.com/docs/report.pdf',
  'Police Report.pdf',
  'application/pdf',
  524288,
  'crm-file-483'
);
```

> **💡 Tip:** Stream upload is useful when your files are in cloud storage (S3, GCS, etc.) and you want to avoid downloading them to your server first.

***

## 💡 Removing Files

**Endpoint:** `POST /organization/{orgId}/inbound-webhooks/case/{mapId}/file/remove`

**Request:**

```bash
curl -X POST \
  "https://integration.lexamica.com/organization/{orgId}/inbound-webhooks/case/{mapId}/file/remove?Key=your_public_key" \
  -H "Content-Type: application/json" \
  -d '{
    "caseId": "64f1a2b3c4d5e6f7a8b9c0d3",
    "attachmentId": "64f1a2b3c4d5e6f7a8b9c0e1"
  }'
```

**Node.js Example:**

```javascript
async function removeFile(caseId, attachmentId) {
  const response = await axios.post(
    `${BASE_URL}/organization/${ORG_ID}/inbound-webhooks/case/${MAPPING_ID}/file/remove`,
    { caseId, attachmentId },
    {
      params: { Key: PUBLIC_KEY },
      headers: { 'Content-Type': 'application/json' }
    }
  );
  
  return response.data;
}
```

***

## 💡 Receiving File Upload Notifications

Get notified when files are uploaded to cases you're involved with.

### Via Webhooks

```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
  }'
```

**Sample webhook payload:**

```json
{
  "case_id": "64f1a2b3c4d5e6f7a8b9c0d3",
  "attachment_id": "64f1a2b3c4d5e6f7a8b9c0e1",
  "filename": "medical_records.pdf",
  "mimetype": "application/pdf",
  "size": 245678,
  "url": "https://storage.lexamica.com/files/64f1a2b3c4d5e6f7a8b9c0e1",
  "uploaded_at": "2026-01-15T10:30:00.000Z"
}
```

> **⚠️ That `url` is short-lived** — see the callout in [Downloading Files](#-downloading-files) below before you write the handler for this event.

### Via Polling

```bash
curl -X POST \
  "https://integration.lexamica.com/organization/{orgId}/stored-event-subscriptions/create" \
  -H "Authorization: Bearer your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "Case File Uploaded",
    "mapping": "your_attachment_mapping_id",
    "description": "Store file upload events for polling",
    "active": true
  }'
```

***

## 💡 Downloading Files

Files include a `url` field that you can use to download the content.

> **⚠️ Act on it immediately — this URL is short-lived:** the `url` field on `Case File Uploaded` (webhook or polling) is a signed URL to Lexamica's file storage that typically expires after a few hours (see FAQ below). Download the file inside the same handler call that receives the notification — don't defer it, and don't persist the URL itself for later use, or it may already be dead by the time you get to it.

**Example:**

```javascript
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);
  });
}

// In your webhook/polling handler:
async function handleFileUploaded(payload) {
  // Download now — the signed URL is short-lived (see callout above).
  await downloadFile(
    payload.url,
    `./downloads/${payload.filename}`
  );
  
  // Record the file's metadata for your own reference. Don't persist
  // payload.url itself as a reusable link — by the time you need it,
  // it may already have expired.
  await db.caseFiles.create({
    caseId: payload.case_id,
    filename: payload.filename,
    size: payload.size,
    mimetype: payload.mimetype
  });
}
```

***

## 🔧 Attachment Mapping

Create an Attachment mapping for file-related events:

```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" }
    ]
  }'
```

**Mapped item store:** keep attachment mappings in their own table, keyed by `attachment_id` — not mixed into whatever mapping you use for cases — so file dedup doesn't collide with case/invitation tracking. This is the same pattern (and the same function names) used in [Originator: Full Integration with Webhooks](/example-integrations/3a.-originator-full-webhook.md)'s Step 7:

```javascript
// Lookup before uploading — dedup on your side (see Uploading Files above)
async function findAttachmentMappedItemByForeignId(foreignFileId) {
  return db.attachmentMappedItems.findOne({ foreignFileId });
}

// Lookup on an incoming "Case File Uploaded" notification — dedup
// (have you already processed this exact attachment?)
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 }
  );
}
```

***

## 🔧 Complete Example: File Sync Service

```javascript
const axios = require('axios');
const FormData = require('form-data');
const fs = require('fs');
const path = require('path');

const config = {
  orgId: process.env.LEXAMICA_ORG_ID,
  publicKey: process.env.LEXAMICA_PUBLIC_KEY,
  privateKey: process.env.LEXAMICA_PRIVATE_KEY,
  attachmentMappingId: process.env.LEXAMICA_ATTACHMENT_MAPPING_ID,
  baseUrl: 'https://integration.lexamica.com'
};

class FileService {
  // Upload file directly. findAttachmentMappedItemByForeignId /
  // saveAttachmentMappedItem are defined in the Attachment Mapping
  // section above — dedup before upload, same as Uploading Files.
  async uploadFile(caseId, filePath, fileName, foreignFileId) {
    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 || path.basename(filePath));
    
    const response = await axios.post(
      `${config.baseUrl}/organization/${config.orgId}/inbound-webhooks/case/${config.attachmentMappingId}/file/upload`,
      form,
      {
        params: { Key: config.publicKey },
        headers: form.getHeaders()
      }
    );
    
    await saveAttachmentMappedItem(foreignFileId, response.data.attachment_id);
    return response.data;
  }

  // Upload file from URL
  async uploadFromUrl(caseId, fileUrl, filename, mimetype, size, foreignFileId) {
    const existing = await findAttachmentMappedItemByForeignId(foreignFileId);
    if (existing) {
      return existing;
    }

    const response = await axios.post(
      `${config.baseUrl}/organization/${config.orgId}/inbound-webhooks/case/${config.attachmentMappingId}/file/stream`,
      { caseId, url: fileUrl, filename, mimetype, size },
      {
        params: { Key: config.publicKey },
        headers: { 'Content-Type': 'application/json' }
      }
    );
    
    await saveAttachmentMappedItem(foreignFileId, response.data.attachment_id);
    return response.data;
  }

  // Remove file
  async removeFile(caseId, attachmentId) {
    const response = await axios.post(
      `${config.baseUrl}/organization/${config.orgId}/inbound-webhooks/case/${config.attachmentMappingId}/file/remove`,
      { caseId, attachmentId },
      {
        params: { Key: config.publicKey },
        headers: { 'Content-Type': 'application/json' }
      }
    );
    
    return response.data;
  }

  // Download file
  async 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);
    });
  }

  // Bulk upload. Each file needs its own foreignId so dedup can tell
  // them apart — reuse whatever ID your CRM already assigns the file.
  async uploadMultiple(caseId, files) {
    const results = [];
    for (const file of files) {
      try {
        const result = await this.uploadFile(caseId, file.path, file.name, file.foreignId);
        results.push({ success: true, ...result });
      } catch (error) {
        results.push({ success: false, file: file.name, error: error.message });
      }
    }
    return results;
  }
}

module.exports = new FileService();
```

***

## ❓ FAQ

### ❓ "What file types are supported?"

Most common file types are supported: PDF, images (JPG, PNG), documents (DOC, DOCX), and more. Contact support for specific format questions.

***

### ❓ "What's the maximum file size?"

Direct upload has practical limits based on timeout. For large files (>50MB), use stream upload with a hosted URL. The stream upload will automatically handle file size limits and will retry the upload if it fails.

***

### ❓ "Are files visible to all case participants?"

Yes, files uploaded to a case are visible to all firms who are confirmed as a participant in the case (originator and handler).

***

### ❓ "How long are file URLs valid?"

File URLs are typically short-lived and expire after a few hours. Store files in your own system if you need guaranteed permanent access.

***

## 🔧 Troubleshooting

**"File upload returns 400"**

* Check that `caseId` is valid and you have access to the case
* Verify the mapping ID is for the Attachment model
* For direct upload, ensure the file field is named `file`

**"Stream upload fails"**

* Ensure the source URL is publicly accessible
* Verify the file exists at the URL
* Check that mimetype matches the actual file

***

*Last updated: January 2026*
