Skip to main content
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:
EnvironmentBase URL
Sandboxhttps://sandbox.pylon.mortgage
Productionhttps://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:
// Point this at sandbox while developing, production when you go live.
const BASE_URL = "https://sandbox.pylon.mortgage";
Access tokens are environment-specific — a sandbox token will not authenticate against production, and vice versa. See the authentication guide for details.

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.

Task documents

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.

General documents

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.
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.
POST {BASE_URL}/api/loan-applications/{loanApplicationId}/borrower-tasks/{taskId}/documents
ParameterLocationDescription
loanApplicationIdPathThe ID of the loan application the task belongs to.
taskIdPathThe ID of the task being fulfilled.
borrower-task-filesBody (multipart)One or more files. Repeat the field to send multiple files.
See Tasks for how tasks are created and how to find the task IDs that need documents.
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}`);
  }
}
Your access token must be authorized for the loan application. Uploads to an application your client can’t access return 403.

General documents that don’t fit a task

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.
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.
If the document does satisfy a task, prefer 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.

The endpoint

General uploads go to a fixed loan endpoint, keyed by loan ID rather than a task:
POST {BASE_URL}/api/loans/{loanId}/documents/underwriting
ParameterLocationDescription
loanIdPathThe ID of the loan to attach the documents to.
document-filesBody (multipart)One or more files. Repeat the field to send multiple files.
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.

Uploading a general document

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 goes through GraphQL to obtain a signed URL, regardless of how the document was uploaded.
1

Query document metadata via GraphQL

Query the loan to get its documents, including a signed download URL for each.
2

Use the download URL

Fetch the file from the signed URL in the documentLink.url field.
3

Handle the file

Process the downloaded file and handle URL expiration if needed.

Querying document metadata

Documents are exposed on the loan query:
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:
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:
Authorization: Bearer YOUR_ACCESS_TOKEN
Remember that tokens are environment-specific. See the authentication guide for details on obtaining and managing access tokens.

File requirements

  • Maximum upload size: 30 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

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