L Lumina Developers
← Back to the site

Build on top of it

A JSON REST API over the same data the app and the dashboard use. Book work from your own website, push jobs in from another system, or pull yesterday’s numbers into a spreadsheet — and get told when something changes rather than polling for it.

Base URL

https://fsm.foundrcode.com/api/v1

Getting started

Every request needs a bearer token and an Accept: application/json header. Sign in to get one:

curl -X POST https://fsm.foundrcode.com/api/v1/auth/token \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"…","device_name":"My integration"}'

Then send it on everything else:

curl https://fsm.foundrcode.com/api/v1/jobs?status=scheduled \
  -H 'Accept: application/json' \
  -H 'Authorization: Bearer lum_…'

Two kinds of token

Signing in issues a token that acts as that person, so every permission the dashboard enforces still applies. An integration key, created in Settings, belongs to the company instead and is limited by its scopes alone.

Scopes

A key only does what you tick. Read and write are separate for every resource, so a key that files jobs cannot read invoices.

One company per key

A token reaches exactly one company. There is no company parameter anywhere — the token decides, so no request can be made to cross into another.

Rate limits

Sixty requests a minute per key by default. Over that you get 429 with a Retry-After header saying how long to wait.

Available scopes

jobs:read jobs:write customers:read customers:write invoices:read invoices:write technicians:read contracts:read contracts:write parts:read reports:read dashboard:read

Authentication

Every request carries a bearer token. Tokens are issued to a person by signing in, or created as integration keys in Settings — those belong to the company rather than to anybody, and can do only what their scopes allow.

POST /api/v1/auth/token

Exchange an email and password for a token.

Body

email
string, required
password
string, required
device_name
string, required — shown in Settings so a lost device can be revoked

Returns token, token_id, scopes, user, company

Rate limited. Five failures from one address and it stops answering for five minutes.

GET /api/v1/me any valid token

Who this token belongs to, and what it may do.

Returns user, company, scopes

POST /api/v1/auth/revoke any valid token

Revoke the token making the request.

Returns revoked

Businesses

An account may run several businesses. A token always reaches exactly one — switching mints a new token and revokes the old one, so a key that leaks still reaches a single business.

GET /api/v1/businesses a token issued to a person

The businesses on this account, with is_current marking the one this token is for.

Returns data[]: id, name, timezone, currency, brand_color, accent_color, is_current

An integration key belongs to a company rather than a person, so it gets 403 here — there is no account behind it to enumerate.

POST /api/v1/businesses/{company}/switch a token issued to a person

Move this device to another business on the same account.

Returns The same body as signing in. Store it the same way.

A company that does not exist and one that is not yours answer identically, so ids cannot be enumerated.

Jobs

The work itself. Creating a job stamps the company from the token — a customer_id belonging to somebody else produces a job nobody can read rather than a cross-tenant link.

GET /api/v1/jobs jobs:read

List jobs.

Query

status
unassigned, scheduled, en_route, in_progress, completed, cancelled
technician_id
integer
from
date
to
date

Returns data[] of jobs with customer, site and technician

GET /api/v1/jobs/{job} jobs:read

One job in full.

POST /api/v1/jobs jobs:write

Book a job.

Body

customer_id
integer, required
title
string, required
site_id
integer
service_id
integer
scheduled_start
ISO 8601 datetime
scheduled_end
ISO 8601 datetime
assigned_technician_id
integer
priority
low, normal, high, urgent

Counts against the plan’s monthly job allowance. Past it, this answers 403 plan_limit_reached.

PATCH /api/v1/jobs/{job} jobs:write

Update a job, including moving it through its statuses.

Body

status
unassigned, scheduled, en_route, in_progress, completed, cancelled
completion_notes
string — what was done, shown on the invoice
POST /api/v1/jobs/{job}/signature jobs:write

Attach the customer’s signature, closing the job out.

Body

data
string, required — PNG as base64, with or without a data: prefix
signer_name
string, required — who signed
signer_title
string
lat
number
lng
number
signed_at
ISO 8601 — when they signed, not when you send it
client_uuid
string — your own id, so a retry records one signature

Send this before marking the job completed, so the record is never a completion with no evidence behind it.

Customers

Customers and the sites they own. A customer’s portal token is never returned — it is a bearer credential for a page anyone holding it can open.

GET /api/v1/customers customers:read

List customers.

Query

search
string
GET /api/v1/customers/{customer} customers:read

One customer, with their sites.

POST /api/v1/customers customers:write

Create a customer.

Body

name
string, required
type
residential or commercial
email
string
phone
string

Counts against the plan’s customer limit.

Invoices

Money owed and money taken. Nothing here exposes a part’s cost price — what you paid a supplier is not what your customer is shown.

GET /api/v1/invoices invoices:read

List invoices.

Query

status
draft, sent, paid, overdue, void
GET /api/v1/invoices/{invoice} invoices:read

One invoice, with its lines.

POST /api/v1/invoices/{invoice}/payments invoices:write

Record a payment against an invoice.

Body

amount
number, required
method
cash, card, transfer, other

Contracts, parts and people

The supporting records: recurring work, stock, and who is on the team.

GET /api/v1/contracts contracts:read

Recurring job schedules.

Query

active
boolean
GET /api/v1/contracts/{contract} contracts:read

One contract and its next visit.

PATCH /api/v1/contracts/{contract} contracts:write

Pause or resume a contract.

Body

is_active
boolean
GET /api/v1/parts parts:read

Stock levels.

Query

low_stock
boolean
GET /api/v1/parts/{part} parts:read

One part.

GET /api/v1/technicians technicians:read

The team, with their current status.

Reporting

Aggregates, for a dashboard of your own.

GET /api/v1/dashboard dashboard:read

Today at a glance: booked, done, unassigned, outstanding.

GET /api/v1/reports/jobs reports:read

Jobs by status and by technician over a period.

Query

from
date
to
date
GET /api/v1/reports/revenue reports:read

Invoiced and collected over a period.

Query

from
date
to
date
GET /api/v1/reports/technicians reports:read

Per-technician completion and hours.

Devices

Where to send a push notification. Used by the mobile app; you are unlikely to need these directly.

POST /api/v1/devices a token issued to a person

Register this device for notifications.

Body

token
string, required
platform
ios or android
device_name
string
DELETE /api/v1/devices a token issued to a person

Stop notifications to this device.

Body

token
string, required

Webhooks

Rather than asking every minute whether anything changed, give us a URL and we will tell you. Add endpoints in Settings, choose the events you care about, and each one gets its own signing secret.

Event Fires when
job.created A job was booked, by anyone — a dispatcher, the API, or a recurring contract.
job.status_changed A job moved. The payload carries both the old status and the new one.
job.completed A technician finished, with the completion notes and the time on site.
invoice.created An invoice was raised against a job.
invoice.paid An invoice was settled in full. Fires once, not once per payment.
customer.created A new customer record, however it arrived.

Verifying a delivery

Every request carries an X-Lumina-Signature header: an HMAC-SHA256 of the raw body, keyed with your endpoint’s secret. Compare it against the raw body before you parse it — a re-encoded payload will not match, and an unverified webhook URL is one anybody who finds it can post to.

// Verify before you trust anything in the body.
$expected = hash_hmac('sha256', $rawBody, $yourEndpointSecret);

if (! hash_equals($expected, $request->header('X-Lumina-Signature'))) {
    abort(401);
}

Retries

A 5xx or a 429 is retried with a widening gap. A 4xx is not — your endpoint has said the request is wrong, and sending it again will not change that.

Dead endpoints

After twenty consecutive failures an endpoint switches itself off, and we tell you. Nobody wants a queue quietly filling for a server that was decommissioned last month.

Order and duplicates

Delivery is not ordered and, on a retry, not exactly once. Key on the record id and treat a repeat as a no-op.

Errors

Failures answer with JSON carrying an error key. Two are worth reading twice: a 404 covers both "no such record" and "belongs to another company", deliberately, and a 402 means the account itself has lapsed rather than anything about your request.

401 invalid_token The token is wrong, revoked, or belongs to a deactivated user.
402 subscription_required The company’s subscription has lapsed. Nothing is readable until it is settled.
403 insufficient_scope A valid token without the scope this route needs.
403 plan_limit_reached The company’s plan does not allow another of these this month.
404 No such record, or one belonging to another company. The two are deliberately indistinguishable.
422 Validation failed. The body names each field and what was wrong with it.
429 Rate limited. Retry-After says how long to wait.