code icon Code

Upload File to Notion

Upload local files to Notion using Notion's file upload API (single-part for ≤20MB, multi-part for larger files)

Source Code

import fs from 'fs';
import path from 'path';
import mime from 'mime-types';

// Constants
const MULTIPART_THRESHOLD = 20 * 1024 * 1024; // 20 MB
const PART_SIZE = 10 * 1024 * 1024; // 10 MB per part

/**
 * Upload a local file to Notion using Notion's file upload API.
 * Files ≤20MB use single-part upload (2 steps: create, send).
 * Files >20MB use multi-part upload (3 steps: create, send parts, complete).
 * @param {string} filePath - Local path to the file to upload
 * @param {string} notionToken - Notion API token
 * @returns {Promise<Object>} Upload result with fileUploadId and metadata
 */
async function uploadFileToNotion(filePath, notionToken) {

  // Read the file
  const fileBuffer = fs.readFileSync(filePath);
  const fileName = path.basename(filePath);
  const fileSize = fileBuffer.length;

  // Detect content type
  const contentType = mime.lookup(filePath) || 'application/octet-stream';

  // Automatically determine upload mode based on file size
  const useMultipart = fileSize > MULTIPART_THRESHOLD;

  // Step 1: Create a file upload
  const createUploadBody = {
    name: fileName,
    content_type: contentType
  };

  if (useMultipart) {
    // Calculate number of parts for multipart upload
    const numberOfParts = Math.ceil(fileSize / PART_SIZE);

    createUploadBody.mode = 'multi_part';
    createUploadBody.number_of_parts = numberOfParts;

    console.log(`[DEBUG] File size ${fileSize} bytes exceeds ${MULTIPART_THRESHOLD} bytes, using multipart upload with ${numberOfParts} parts`);
  } else {
    // Use single part upload for smaller files
    createUploadBody.mode = 'single_part';

    console.log(`[DEBUG] File size ${fileSize} bytes, using single-part upload`);
  }

  const createUploadResponse = await fetch('https://api.notion.com/v1/file_uploads', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${notionToken}`,
      'Content-Type': 'application/json',
      'Notion-Version': '2022-06-28'
    },
    body: JSON.stringify(createUploadBody)
  });

  const createUploadData = await createUploadResponse.json();

  if (!createUploadResponse.ok) {
    throw new Error(`Failed to create file upload: ${createUploadData.message || createUploadData.error}`);
  }

  const { id: fileUploadId } = createUploadData;

  // Step 2: Send the file contents
  if (useMultipart) {
    // Send file in multiple parts
    const numberOfParts = Math.ceil(fileSize / PART_SIZE);

    for (let partNumber = 1; partNumber <= numberOfParts; partNumber++) {
      const start = (partNumber - 1) * PART_SIZE;
      const end = Math.min(start + PART_SIZE, fileSize);
      const partBuffer = fileBuffer.subarray(start, end);

      const formData = new FormData();
      formData.append('file', new Blob([partBuffer], { type: contentType }), fileName);
      formData.append('part_number', partNumber.toString());

      console.log(`[DEBUG] Uploading part ${partNumber}/${numberOfParts} (${partBuffer.length} bytes)`);

      const sendUploadResponse = await fetch(
        `https://api.notion.com/v1/file_uploads/${fileUploadId}/send`,
        {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${notionToken}`,
            'Notion-Version': '2022-06-28'
          },
          body: formData
        }
      );

      if (!sendUploadResponse.ok) {
        const sendUploadError = await sendUploadResponse.text();
        throw new Error(`Failed to send file part ${partNumber}: ${sendUploadError}`);
      }
    }

    // Step 3 (multi-part only): Complete the file upload
    // The /complete endpoint is REQUIRED for multi-part uploads to finalize the upload
    const completeUploadResponse = await fetch(
      `https://api.notion.com/v1/file_uploads/${fileUploadId}/complete`,
      {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${notionToken}`,
          'Content-Type': 'application/json',
          'Notion-Version': '2022-06-28'
        }
      }
    );

    if (!completeUploadResponse.ok) {
      const completeUploadData = await completeUploadResponse.json();
      throw new Error(`Failed to complete file upload: ${completeUploadData.message || completeUploadData.error}`);
    }

    console.log(`[DEBUG] Completed multi-part upload`);
  } else {
    // Send file in single part
    // For single-part uploads, the file auto-transitions to 'uploaded' status after send
    const formData = new FormData();
    formData.append('file', new Blob([fileBuffer], { type: contentType }), fileName);

    const sendUploadResponse = await fetch(
      `https://api.notion.com/v1/file_uploads/${fileUploadId}/send`,
      {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${notionToken}`,
          'Notion-Version': '2022-06-28'
        },
        body: formData
      }
    );

    if (!sendUploadResponse.ok) {
      const sendUploadError = await sendUploadResponse.text();
      throw new Error(`Failed to send file contents: ${sendUploadError}`);
    }
  }

  // File is now uploaded and ready to use - attach to pages/blocks using fileUploadId
  console.log(`[DEBUG] Successfully uploaded file to Notion: ${fileName}`);
  console.log(`[DEBUG] File Upload ID: ${fileUploadId}`);
  console.log(`[DEBUG] Use fileUploadId with type: "file_upload" to attach to pages or blocks`);

  return {
    success: true,
    fileUploadId: fileUploadId,
    fileName: fileName,
    contentType: contentType,
    fileSize: fileSize
  };
}


export { uploadFileToNotion };

/**
 * Run this script directly from the command line:
 * bun upload_file.js <filePath> <notionToken>
 *
 * The script automatically determines whether to use single-part or multi-part upload
 * based on file size (files >20MB use multi-part upload automatically).
 *
 * Returns a fileUploadId which you can then use in API calls to add to pages/databases
 * with type: "file_upload".
 *
 * Examples:
 * # Upload local file
 * bun upload_file.js ./document.pdf token123
 *
 * # Upload image
 * bun upload_file.js ./photo.jpg token123
 */
if (import.meta.main) {
  const args = process.argv.slice(2);
  if (args.length < 2) {
    console.error('Usage: bun upload_file.js <filePath> <notionToken>');
    console.error('');
    console.error('The script automatically uses multi-part upload for files >20MB.');
    console.error('Returns a fileUploadId to use with type: "file_upload" in API calls.');
    console.error('');
    console.error('Examples:');
    console.error('  # Upload local file');
    console.error('  bun upload_file.js ./doc.pdf token123');
    console.error('');
    console.error('  # Upload image');
    console.error('  bun upload_file.js ./photo.jpg token123');
    process.exit(1);
  }

  const filePath = args[0];
  const notionToken = args[1];

  uploadFileToNotion(filePath, notionToken)
    .then(result => {
      console.log('\n✓ Upload successful!');
      console.log(`File Upload ID: ${result.fileUploadId}`);
      console.log(`File Name: ${result.fileName}`);
      console.log(`File Size: ${result.fileSize} bytes`);
      console.log(`Content Type: ${result.contentType}`);
      console.log('\nUse the File Upload ID with type: "file_upload" in Notion API calls.');
    })
    .catch(error => {
      console.error('Upload failed:', error.message);
      process.exit(1);
    });
}