New KPMG Agentic ERP, now available with KPMG in 140+ countries Read the announcement

API/Examples

Create an invoice payable

End-to-end: create a vendor invoice with line items and a document, then submit it for approval.

Section
Examples
Reading
4 min read
On this page
  1. 01Prepare the saved request
  2. 02Run each step separately

Create the bill and its inline lines in one request, upload the document, then submit for approval. Create and submit each accept X-Idempotency-Key; the upload does not. Separate line-item creation also does not accept the header, which is why this example uses inline lineItems. Read Idempotency before adding retries.

Prepare the saved request

Before the first attempt, save the create payload as bill.json and two unique operation keys as BILL_CREATE_KEY and BILL_SUBMIT_KEY in your worker's environment. Generate the keys once and persist them with the work item; load the same values on restart. Do not edit the saved payload or regenerate keys on a retry. Use the same authenticated user or service account throughout.

Replace the example IDs with your vendor, entity, GBP bank account, expense account and tax code. Use dates in an open accounting period and the invoice's own number. The vendor must be enabled for the entity and have complete payment details. Configure a company bill-approval workflow that assigns approvers. Add any custom properties your company requires. The credential needs permission to create and submit bills.

Amounts are minor units. These two gross lines sum to 150,000 GBP minors (£1,500); tax is recomputed from your tax code. With lineItemsIncludeTax: true, send amount, not netAmount.

{
  "vendorId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
  "companyEntityId": "dd991863-2e80-4a47-a917-a289bcba7e23",
  "senderBankAccountId": "bfd13110-fb91-48ec-b65a-3ca2cebbcc55",
  "invoiceNumber": "INV-2026-042",
  "issuedDate": "2026-10-08",
  "dueDate": "2026-11-07",
  "paymentAt": "2026-11-07T09:00:00Z",
  "currency": "GBP",
  "amount": 150000,
  "processingMode": "DATA_ONLY",
  "lineItemsIncludeTax": true,
  "lineItems": [
    {
      "description": "Software licence",
      "amount": 120000,
      "accountId": "eb7f9d33-34d2-4d1b-a787-d388619c5652",
      "taxCodeId": "08b92767-8745-4c87-9ea9-f5b60aad5d89"
    },
    {
      "description": "Support contract",
      "amount": 30000,
      "accountId": "eb7f9d33-34d2-4d1b-a787-d388619c5652",
      "taxCodeId": "08b92767-8745-4c87-9ea9-f5b60aad5d89"
    }
  ]
}

Run each step separately

The functions below deliberately keep the three steps separate. Call createBill() / create_bill() and persist its returned ID before continuing. Then attach the PDF with attachDocument(id) / attach_document(id), and call submitBill(id) / submit_bill(id) once the attachment is visible. Keep the PDF beside bill.json as invoice-042.pdf.

Resume at the failed step, not by retrying the whole workflow. Within the retention window, repeating create or submit with its saved key and unchanged request replays the original response. On a timeout, 5xx or in-progress conflict, use bounded backoff as described in Idempotency; a violation or validation failure needs investigation, not an automatic new key. The functions surface errors rather than retrying writes indiscriminately.

const fs = require('node:fs');
const BASE_URL = 'https://api.light.inc';
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

function savedKey(name) {
  const key = process.env[name];
  if (!key?.trim()) throw new Error(`Set ${name} to this operation's saved key`);
  return key;
}

const createKey = savedKey('BILL_CREATE_KEY');
const submitKey = savedKey('BILL_SUBMIT_KEY');
const createBody = fs.readFileSync('./bill.json', 'utf8');

async function api(path, options = {}) {
  const res = await fetch(`${BASE_URL}${path}`, {
    ...options,
    headers: {
      Authorization: `Basic ${process.env.LIGHT_API_KEY}`,
      'Content-Type': 'application/json',
      ...options.headers,
    },
    signal: AbortSignal.timeout(30000),
  });
  const text = await res.text();
  if (!res.ok) throw new Error(`${res.status}: ${text}`);
  return JSON.parse(text);
}

async function createBill() {
  return api('/v1/invoice-payables', {
    method: 'POST',
    headers: { 'X-Idempotency-Key': createKey },
    body: createBody,
  });
}

async function attachDocument(id) {
  const upload = await api(`/v1/invoice-payables/${id}/document/upload-url`, {
    method: 'POST',
    body: JSON.stringify({ documentName: 'invoice-042.pdf', contentType: 'application/pdf' }),
  });
  const res = await fetch(upload.uploadUrl, {
    method: 'PUT',
    headers: upload.headers, // All signed headers; no Light Authorization header.
    body: fs.readFileSync('./invoice-042.pdf'),
    signal: AbortSignal.timeout(30000),
  });
  if (!res.ok) throw new Error(`Upload failed: ${res.status}`);

  for (let attempt = 0; attempt < 30; attempt++) {
    const bill = await api(`/v1/invoice-payables/${id}`);
    if (bill.documentName) return bill;
    await sleep(1000);
  }
  throw new Error('Attachment still pending; resume polling GET before submitting');
}

async function submitBill(id) {
  return api(`/v1/invoice-payables/${id}/submit-for-approval`, {
    method: 'POST',
    headers: { 'X-Idempotency-Key': submitKey },
    body: JSON.stringify({ closePurchaseOrder: false }),
  });
}
import json
import os
import time
from pathlib import Path
import requests

BASE_URL = 'https://api.light.inc'

def saved_key(name):
    key = os.environ[name]
    if not key.strip():
        raise ValueError(f"Set {name} to this operation's saved key")
    return key

CREATE_KEY = saved_key('BILL_CREATE_KEY')
SUBMIT_KEY = saved_key('BILL_SUBMIT_KEY')
CREATE_BODY = json.loads(Path('bill.json').read_text())

def api(method, path, body=None, key=None):
    headers = {'Authorization': f"Basic {os.environ['LIGHT_API_KEY']}"}
    if key is not None:
        headers['X-Idempotency-Key'] = key
    res = requests.request(method, BASE_URL + path, headers=headers, json=body, timeout=30)
    if not res.ok:
        raise RuntimeError(f'{res.status_code}: {res.text}')
    return res.json()

def create_bill():
    return api('POST', '/v1/invoice-payables', body=CREATE_BODY, key=CREATE_KEY)

def attach_document(bill_id):
    upload = api('POST', f'/v1/invoice-payables/{bill_id}/document/upload-url', body={
        'documentName': 'invoice-042.pdf', 'contentType': 'application/pdf',
    })
    res = requests.put(
        upload['uploadUrl'],
        headers=upload['headers'],  # All signed headers; no Light Authorization header.
        data=Path('invoice-042.pdf').read_bytes(),
        timeout=30,
    )
    res.raise_for_status()
    for _ in range(30):
        bill = api('GET', f'/v1/invoice-payables/{bill_id}')
        if bill.get('documentName'):
            return bill
        time.sleep(1)
    raise RuntimeError('Attachment still pending; resume polling GET before submitting')

def submit_bill(bill_id):
    return api('POST', f'/v1/invoice-payables/{bill_id}/submit-for-approval',
               body={'closePurchaseOrder': False}, key=SUBMIT_KEY)

If the attachment poll times out, keep polling GET rather than uploading again automatically. A create key does not protect repeated uploads. After submission, poll GET for the current state and any failureContext; the cached submit response is not a progress update. A deliberate resubmission after correcting an asynchronous failure is a new operation and needs a new saved submit key.