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
- Your app asks Light for an upload URL —
POSTto the relevantupload-urlendpoint. - Light returns
{ "uploadUrl": "https://…" }, a short-lived presigned URL on its storage provider. - 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-urlreturns ametadataobject. Send every entry as a request header on thePUT— 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. SetshouldAutoCreateExpense: falseto 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).originalAmountis inoriginalCurrency;billingAmountis in the user's reimbursement currency and may be omitted — it is copied when the currencies match and derived from the exchange rate ofperformedDateotherwise. - Every line needs a
reimbursementCategoryId: the category decides the GL account and tax code on the reimbursement. List the available categories withGET /v1/reimbursement-categories. - Send an
X-Idempotency-Keyheader to retry safely. A receipt can be used by one expense only (409otherwise).
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 |
|---|---|---|
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.