> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pylon.mortgage/llms.txt
> Use this file to discover all available pages before exploring further.

# Working with documents

> Upload and download loan documents over REST — for specific tasks, or as general documents that don't fit a task

GraphQL is designed for structured data queries and mutations, but it doesn't natively support file uploads or downloads. To handle documents (PDFs, images, and other files), Pylon uses REST endpoints for transferring files, while GraphQL is used to read document metadata and obtain download links.

## Why REST for documents?

GraphQL operates with JSON payloads and doesn't have built-in support for binary file data. For document operations, Pylon uses REST endpoints that:

* Accept file uploads via `multipart/form-data`
* Handle large file transfers efficiently

GraphQL still plays a role on the way out: you query document metadata through GraphQL to get a signed URL, then download the file from that URL.

## Environments

Every endpoint on this page is available in both the sandbox and production environments. The only difference is the host:

| Environment | Base URL                         |
| ----------- | -------------------------------- |
| Sandbox     | `https://sandbox.pylon.mortgage` |
| Production  | `https://pylon.mortgage`         |

Use the sandbox for development and testing, and switch the base URL to production when you go live. The examples below read the host from a `BASE_URL` constant so you only change it in one place:

```javascript theme={null}
// Point this at sandbox while developing, production when you go live.
const BASE_URL = "https://sandbox.pylon.mortgage";
```

<Note>
  Access tokens are environment-specific — a sandbox token will not authenticate against production, and vice versa. See the [authentication](/playground/authentication) guide for details.
</Note>

## Two ways to upload a document

Pylon has two distinct upload endpoints. Choosing the right one keeps documents correctly organized on the loan. Both are REST endpoints that accept `multipart/form-data` and are authorized with the `create:document` scope.

<CardGroup cols={2}>
  <Card title="Task documents" icon="list-check" href="/entity-models/tasks">
    The document satisfies a specific borrower **task** — a W-2 for income verification, a gift letter, a payoff demand. Upload it to the task endpoint, which attaches the file **and marks the task complete**.
  </Card>

  <Card title="General documents" icon="folder-open" href="#general-documents-that-don-t-fit-a-task">
    The document belongs on the loan but **doesn't cleanly fit any task** — supplemental correspondence, an ad-hoc statement, a miscellaneous file. Upload it as a general document attached directly to the loan.
  </Card>
</CardGroup>

The rest of this page covers both paths, then downloading.

## Uploading documents for a task

When a document fulfills a specific task, upload it to that task's document endpoint. The upload attaches the file to the task and completes the task in a single call — there is no separate "request an upload URL" step.

```http theme={null}
POST {BASE_URL}/api/loan-applications/{loanApplicationId}/borrower-tasks/{taskId}/documents
```

| Parameter             | Location         | Description                                                 |
| --------------------- | ---------------- | ----------------------------------------------------------- |
| `loanApplicationId`   | Path             | The ID of the loan application the task belongs to.         |
| `taskId`              | Path             | The ID of the task being fulfilled.                         |
| `borrower-task-files` | Body (multipart) | One or more files. Repeat the field to send multiple files. |

See [Tasks](/entity-models/tasks) for how tasks are created and how to find the task IDs that need documents.

```javascript theme={null}
async function uploadTaskDocument(loanApplicationId, taskId, files) {
  const formData = new FormData();
  files.forEach((file) => {
    formData.append("borrower-task-files", file);
  });

  const response = await fetch(
    `${BASE_URL}/api/loan-applications/${loanApplicationId}/borrower-tasks/${taskId}/documents`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
      body: formData,
    }
  );

  if (!response.ok) {
    throw new Error(`Upload failed: ${response.statusText}`);
  }
}
```

<Note>
  Your access token must be authorized for the loan application. Uploads to an application your client can't access return `403`.
</Note>

## General documents that don't fit a task

<Note>
  **Not every document maps to a task.** When you have a file that belongs on the loan but doesn't correspond to any particular task, use the general upload endpoint below. Files uploaded this way are attached directly to the loan and filed as **Uncategorized**, with no task association.
</Note>

This is the right path for supplemental correspondence, an ad-hoc statement a borrower sent over, or any miscellaneous file you want stored against the loan record.

<Tip>
  If the document *does* satisfy a task, prefer [Uploading documents for a task](#uploading-documents-for-a-task) instead — that keeps the document tied to its task and lets the task be marked complete. The task catalog and the documents each task expects are documented under [Tasks](/entity-models/tasks).
</Tip>

### The endpoint

General uploads go to a fixed loan endpoint, keyed by loan ID rather than a task:

```http theme={null}
POST {BASE_URL}/api/loans/{loanId}/documents/underwriting
```

| Parameter        | Location         | Description                                                 |
| ---------------- | ---------------- | ----------------------------------------------------------- |
| `loanId`         | Path             | The ID of the loan to attach the documents to.              |
| `document-files` | Body (multipart) | One or more files. Repeat the field to send multiple files. |

<Note>
  Note the different multipart field name (`document-files`) and that this endpoint takes a **loan ID**, whereas the task endpoint takes a **loan application ID** and a task ID. Your access token must be authorized for the loan.
</Note>

### Uploading a general document

```javascript theme={null}
async function uploadGeneralDocuments(loanId, files) {
  const formData = new FormData();
  files.forEach((file) => {
    formData.append("document-files", file);
  });

  const response = await fetch(
    `${BASE_URL}/api/loans/${loanId}/documents/underwriting`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
      body: formData,
    }
  );

  if (!response.ok) {
    throw new Error(`Upload failed: ${response.statusText}`);
  }
}
```

A successful upload returns a `200` response with an empty body. To confirm the documents landed and retrieve download URLs, query the loan's documents through GraphQL as shown in [Downloading documents](#downloading-documents).

## Downloading documents

Downloading goes through GraphQL to obtain a signed URL, regardless of how the document was uploaded.

<Steps>
  <Step title="Query document metadata via GraphQL">
    Query the loan to get its documents, including a signed download URL for each.
  </Step>

  <Step title="Use the download URL">
    Fetch the file from the signed URL in the `documentLink.url` field.
  </Step>

  <Step title="Handle the file">
    Process the downloaded file and handle URL expiration if needed.
  </Step>
</Steps>

### Querying document metadata

Documents are exposed on the `loan` query:

```graphql theme={null}
query GetLoanDocuments($loanId: ID!) {
  loan(id: $loanId) {
    id
    documents {
      id
      name
      category
      uploadedAt
      documentLink {
        url
        title
        uploadStatus
      }
    }
  }
}
```

The `documentLink.url` field is a signed download URL. The `uploadStatus` is one of `UPLOADING`, `COMPLETE`, or `FAILED`.

### Using download URLs

Signed download URLs expire after about an hour, so fetch them shortly after querying:

```javascript theme={null}
async function downloadDocument(doc) {
  // Only completed uploads have a usable download URL
  if (doc.documentLink.uploadStatus !== "COMPLETE") {
    throw new Error("Document upload is not complete yet");
  }

  const response = await fetch(doc.documentLink.url);

  if (!response.ok) {
    throw new Error(`Download failed: ${response.statusText}`);
  }

  return response.blob();
}
```

## Authentication

Both upload endpoints use the same OAuth Bearer token authentication as GraphQL requests, and require a token with the `create:document` scope:

```http theme={null}
Authorization: Bearer YOUR_ACCESS_TOKEN
```

Remember that tokens are environment-specific. See the [authentication](/playground/authentication) guide for details on obtaining and managing access tokens.

## Attributing an upload

By default an uploaded document is attributed to whoever the token represents — your integration, for a machine credential. To record a **specific** uploader instead, a machine credential can add the `x-pylon-on-behalf-of` header to either upload call:

```javascript theme={null}
const response = await fetch(
  `${BASE_URL}/api/loans/${loanId}/documents/underwriting`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${accessToken}`,
      "x-pylon-on-behalf-of": "cont_123ABC456EXAMPLE", // or a borr_… id
    },
    body: formData,
  }
);
```

The value is a contributor id (`cont_…`) or a borrower id (`borr_…`); the uploader then appears on the document's `uploadedBy` field. This is attribution only — it never changes what your token is allowed to do. See [Acting on behalf of a user](/guides/getting-started/authentication/overview#acting-on-behalf-of-a-user) for the full rules and [Contributors](/guides/getting-started/contributors) for the concept.

## File requirements

* **Maximum upload size**: 50 MB per request (across all files in the request).
* **File types**: There is no enforced file-type restriction, but you should upload standard document formats — PDFs, images (JPG, PNG), and Office documents.

## Error handling

```javascript theme={null}
async function uploadWithErrorHandling(url, fieldName, files) {
  try {
    const formData = new FormData();
    files.forEach((file) => formData.append(fieldName, file));

    const response = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
      body: formData,
    });

    if (!response.ok) {
      if (response.status === 401) {
        throw new Error("Authentication failed. Check your access token.");
      } else if (response.status === 403) {
        throw new Error("Your client is not authorized for this loan.");
      } else if (response.status === 413) {
        throw new Error("Upload too large. The limit is 50 MB per request.");
      }
      throw new Error(`Upload failed: ${response.statusText}`);
    }
  } catch (error) {
    console.error("Document upload error:", error);
    throw error;
  }
}
```

## Best practices

1. **Pick the right upload path** - Use the task endpoint for documents that fulfill a task; use the general endpoint only for files that don't fit one. This keeps the loan's documents organized.

2. **Stay under the size limit** - Each request is capped at 50 MB across all files. Split large batches across multiple requests.

3. **Handle URL expiration** - Download URLs expire after about an hour. Re-query document metadata to get fresh URLs if needed.

4. **Check upload status** - Verify `uploadStatus` is `COMPLETE` before attempting to download.

## Related resources

* [Tasks](/entity-models/tasks) - The task catalog and the documents each task expects
* [Authentication](/playground/authentication) - Obtaining and managing access tokens
* [Forming calls with GraphQL](/playground/forming-calls) - GraphQL query and mutation basics
