Light APIv1.0.0

API / Examples

Upload a receipt

Upload receipts for expenses and card transactions using presigned URLs.

Light uses presigned URLs for file uploads. The two-step process keeps your API key out of the upload itself and supports files of any size.

How it works

  1. Your app asks Light for an upload URL — POST to the relevant upload-url endpoint.
  2. Light returns { "uploadUrl": "https://…" }, a short-lived presigned URL on its storage provider.
  3. Your app PUTs the file straight to that URL. The file never passes through the Light API.

Upload a receipt for a card transaction

const fs = require('fs');

async function uploadReceipt(cardTransactionId, filePath) {
  const API_KEY = process.env.LIGHT_API_KEY;
  const fileName = filePath.split('/').pop();
  const contentType = fileName.endsWith('.pdf')
    ? 'application/pdf'
    : 'image/jpeg';

  // Step 1: Get a presigned upload URL
  const urlRes = await fetch(
    `https://api.light.inc/v1/card-transactions/${cardTransactionId}/receipt-upload-url`,
    {
      method: 'POST',
      headers: {
        'Authorization': `Basic ${API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ fileName, contentType }),
    }
  );
  const { uploadUrl } = await urlRes.json();

  // Step 2: Upload the file directly
  const fileBuffer = fs.readFileSync(filePath);
  const uploadRes = await fetch(uploadUrl, {
    method: 'PUT',
    headers: { 'Content-Type': contentType },
    body: fileBuffer,
  });

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

  console.log('Receipt uploaded successfully');
}

uploadReceipt('3c90c3cc-0d44-4b50-8888-8dd25736052a', './receipts/coffee.jpg');
import requests
import os
import mimetypes

def upload_receipt(card_transaction_id: str, file_path: str):
    api_key = os.environ['LIGHT_API_KEY']
    file_name = os.path.basename(file_path)
    content_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream'

    # Step 1: Get a presigned upload URL
    url_res = requests.post(
        f'https://api.light.inc/v1/card-transactions/{card_transaction_id}/receipt-upload-url',
        headers={
            'Authorization': f'Basic {api_key}',
            'Content-Type': 'application/json',
        },
        json={'fileName': file_name, 'contentType': content_type},
    )
    upload_url = url_res.json()['uploadUrl']

    # Step 2: Upload the file directly
    with open(file_path, 'rb') as f:
        upload_res = requests.put(
            upload_url,
            headers={'Content-Type': content_type},
            data=f.read(),
        )
    upload_res.raise_for_status()
    print('Receipt uploaded successfully')

upload_receipt('3c90c3cc-0d44-4b50-8888-8dd25736052a', './receipts/coffee.jpg')

Upload a receipt for an expense

Expense receipts use the same two-step flow, with two differences from card transactions:

  • POST /v1/expenses/upload-url returns a metadata object. Send every entry as a request header on the PUT — the headers are part of the presigned signature, and storage rejects the upload without them.
  • By default (shouldAutoCreateExpense: true, as in the examples below) Light converts the receipt to PDF, creates the expense automatically and extracts its fields with OCR. Set shouldAutoCreateExpense: false to only store the receipt — no expense is created and nothing is parsed — when your integration creates the expense itself through the API.
const fs = require('fs');

async function uploadExpenseReceipt(filePath) {
  const API_KEY = process.env.LIGHT_API_KEY;
  const filename = filePath.split('/').pop();
  const contentType = filename.endsWith('.pdf') ? 'application/pdf' : 'image/jpeg';

  // Step 1: Get a presigned upload URL
  const urlRes = await fetch('https://api.light.inc/v1/expenses/upload-url', {
    method: 'POST',
    headers: {
      'Authorization': `Basic ${API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ filename, contentType }),
  });
  const { uploadUrl, key, metadata } = await urlRes.json();

  // Step 2: Upload the file directly, echoing the metadata as headers
  const uploadRes = await fetch(uploadUrl, {
    method: 'PUT',
    headers: { 'Content-Type': contentType, ...metadata },
    body: fs.readFileSync(filePath),
  });

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

  return key; // the receipt's storage key
}

uploadExpenseReceipt('./receipts/taxi.jpg');
import requests
import os
import mimetypes

def upload_expense_receipt(file_path: str) -> str:
    api_key = os.environ['LIGHT_API_KEY']
    filename = os.path.basename(file_path)
    content_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream'

    # Step 1: Get a presigned upload URL
    url_res = requests.post(
        'https://api.light.inc/v1/expenses/upload-url',
        headers={
            'Authorization': f'Basic {api_key}',
            'Content-Type': 'application/json',
        },
        json={'filename': filename, 'contentType': content_type},
    )
    url_res.raise_for_status()
    body = url_res.json()

    # Step 2: Upload the file directly, echoing the metadata as headers
    with open(file_path, 'rb') as f:
        upload_res = requests.put(
            body['uploadUrl'],
            headers={'Content-Type': content_type, **(body.get('metadata') or {})},
            data=f.read(),
        )
    upload_res.raise_for_status()
    return body['key']  # the receipt's storage key

upload_expense_receipt('./receipts/taxi.jpg')

Create an expense without OCR

When your integration already knows the expense data (per diems, mileage and so on) you don't need Light to read the receipt. Upload it with shouldAutoCreateExpense: false, then create the expense yourself with POST /v1/expenses. The call returns the finished expense with its ID — no polling, no OCR — and the expense is a draft for the authenticated user until POST /v1/expenses/submit, which submits all of that user's drafts.

  • A PDF receipt is attached as-is (~1s). JPEG, PNG, HEIC and TIFF receipts are converted to PDF during the call (a few seconds). Receipts are limited to 10 MB.
  • Amounts are integers in minor units (1250 = 12.50). originalAmount is in originalCurrency; billingAmount is in the user's reimbursement currency and may be omitted — it is copied when the currencies match and derived from the exchange rate of performedDate otherwise.
  • Every line needs a reimbursementCategoryId: the category decides the GL account and tax code on the reimbursement. List the available categories with GET /v1/reimbursement-categories.
  • Send an X-Idempotency-Key header to retry safely. A receipt can be used by one expense only (409 otherwise).
const fs = require('fs');

async function createExpense(filePath, expense) {
  const API_KEY = process.env.LIGHT_API_KEY;
  const headers = { 'Authorization': `Basic ${API_KEY}`, 'Content-Type': 'application/json' };
  const filename = filePath.split('/').pop();
  const contentType = filename.endsWith('.pdf') ? 'application/pdf' : 'image/jpeg';

  // 1. Presign — store only, do not auto-create
  const urlRes = await fetch('https://api.light.inc/v1/expenses/upload-url', {
    method: 'POST',
    headers,
    body: JSON.stringify({ filename, contentType, shouldAutoCreateExpense: false }),
  });
  const { uploadUrl, key, metadata } = await urlRes.json();

  // 2. Upload, echoing the metadata headers
  const uploadRes = await fetch(uploadUrl, {
    method: 'PUT',
    headers: { 'Content-Type': contentType, ...metadata },
    body: fs.readFileSync(filePath),
  });
  if (!uploadRes.ok) throw new Error(`Upload failed: ${uploadRes.status}`);

  // 3. Create the expense with your own data
  const createRes = await fetch('https://api.light.inc/v1/expenses', {
    method: 'POST',
    headers: { ...headers, 'X-Idempotency-Key': expense.idempotencyKey },
    body: JSON.stringify({
      receiptDocumentKey: key,
      originalCurrency: 'USD',
      performedDate: '2026-08-20',
      detailedDescription: 'Per diem – Austin trip',
      lineItems: [
        { originalAmount: 7400, description: 'Per diem day 1', reimbursementCategoryId: expense.categoryId },
      ],
    }),
  });
  return createRes.json(); // { id, status: "IN_DRAFT", receiptDocumentKey, lineItems, ... }
}

Supported file types

Type Content type Max size
PDF application/pdf 10 MB
JPEG image/jpeg 10 MB
PNG image/png 10 MB
HEIC / HEIF image/heic, image/heif 10 MB
TIFF image/tiff 10 MB

Tip. Presigned URLs expire after a short period. Upload the file immediately after obtaining the URL; if it expires, request a new one.