Work orders from your ERP
Create work orders from your ERP, let your technicians finish them in Klantly and fetch the completed work for invoicing.
Your ERP knows which work needs to be done; your technicians work in Klantly. With the API you prepare the work order, with its lines and checklist, and fetch it back as soon as the work is done and signed.
What you need
An API key with these scopes:
| Scope | What for |
|---|---|
customers.read |
Look up the customer by email address. |
customers.write |
Create a customer that does not exist yet. |
users.read |
Optional: look up the technician who does the work. |
work_orders.read |
Fetch completed work orders. |
work_orders.write |
Create and update work orders and change their status. |
The Work orders feature must be enabled for your company.
Step 1: customer and technician
A work order always belongs to a customer. Look them up with filter[email] or create them, as in Website form to lead. To assign the work order to a technician right away, get their id with GET /users and store it in your ERP.
Step 2: create the work order
curl -X POST "https://app.klantly.com/api/v1/work-orders" \
-H "Authorization: Bearer $KLANTLY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: erp-order-20260142" \
-d '{
"customer_id": "9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70",
"status": "planned",
"title": "Boiler maintenance",
"type": "maintenance",
"scheduled_at": "2026-10-06T08:00:00+02:00",
"assigned_user_id": "usr_0k3j9x21m4zq8p",
"items": [
{"type": "labor", "name": "Maintenance service", "quantity": 1, "unit": "hour", "unit_price": 85},
{"type": "material", "name": "Filter set", "sku": "FLT-200", "quantity": 1, "unit_price": 24.5}
],
"checklist": [
{"label": "Pressure checked", "required": true},
{"label": "Flue gas measured", "required": true}
]
}'- Klantly gives the work order a number (
number, for exampleWB-2026-00042) and calculates the totals. Prices exclude VAT. Give a line discount as a percentage (discount_percentage) or as a fixed amount (discount_amount). A line and the total may not exceed 99,999,999.99. - Name, address and contact details come from the customer, as they are at that moment.
- A new work order is
draft(default) orplanned. - If the work order belongs to an appointment, send
appointment_id. - Put the order number from your ERP in the
Idempotency-Key, and store theidandnumberfrom the response in your ERP.
Step 3: pass on changes
Send only what changes:
curl -X PATCH "https://app.klantly.com/api/v1/work-orders/5e2c8a91-3f4b-4d6e-8a7c-1b2d3e4f5a60" \
-H "Authorization: Bearer $KLANTLY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"scheduled_at": "2026-10-07T08:00:00+02:00"}'items and checklist always replace the whole list.
Warning
Only send checklist if you really want to replace it: otherwise the points your technician already ticked off are gone. The same goes for items and the lines they added on site.
If the work does not go ahead, cancel the work order with its status:
curl -X POST "https://app.klantly.com/api/v1/work-orders/5e2c8a91-3f4b-4d6e-8a7c-1b2d3e4f5a60/status" \
-H "Authorization: Bearer $KLANTLY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: erp-order-20260142-cancel" \
-d '{"status": "cancelled"}'Once a work order has been invoiced in Klantly (invoiced), it can no longer change: you get 409 with the code invalid_state_transition.
Step 4: fetch the completed work
As soon as your technician completes the work order, Klantly sends the work_order.completed event. If the customer signs the work order, that completes it at once: you get work_order.signed and work_order.completed together, in any order. Listen to them with a webhook and you know right away.
If you prefer to fetch yourself, for example every fifteen minutes, request the work orders completed since your previous run:
curl --globoff "https://app.klantly.com/api/v1/work-orders?filter[status]=completed&filter[updated_since]=2026-10-06T00:00:00Z&limit=100" \
-H "Authorization: Bearer $KLANTLY_API_KEY"Page on with next_cursor until it is null, see Pagination. Remember the moment your run started, minus a minute of margin for clock skew, and use it next time as filter[updated_since]: that way you miss nothing that changes while you fetch. A work order can therefore come by twice, for example when the customer signs later; deduplicate on id.
What you get back:
| Field | What it contains |
|---|---|
work_performed |
What the technician did. |
items |
The lines, including what the technician added on site, with minutes for labour. |
checklist |
What was ticked off, with notes. |
signature |
Whether the customer signed, with name and time. |
subtotal, tax_amount, total |
The totals, as strings with two decimals. |
If you invoice from your ERP, the work order stays completed in Klantly: only Klantly itself sets the invoiced status, when you invoice in Klantly.
All together
use GuzzleHttp\Client;
/**
* Fetches the work orders completed since $since and passes them one by one to $handle.
* Returns the moment to use as $since next time.
*/
function fetchCompletedWorkOrders(Client $klantly, string $since, callable $handle): string
{
$startedAt = gmdate('Y-m-d\TH:i:s\Z', time() - 60); // One minute of margin for clock skew.
$cursor = null;
do {
$page = json_decode((string) $klantly->get('work-orders', [
'query' => array_filter([
'filter' => ['status' => 'completed', 'updated_since' => $since],
'limit' => 100,
'cursor' => $cursor,
]),
])->getBody(), true);
foreach ($page['data'] as $workOrder) {
$handle($workOrder); // For example, create an invoice in your ERP; deduplicate on $workOrder['id'].
}
$cursor = $page['meta']['next_cursor'];
} while ($cursor !== null);
return $startedAt;
}const BASE_URL = 'https://app.klantly.com/api/v1';
async function klantly(path) {
const response = await fetch(`${BASE_URL}/${path}`, {
headers: { Authorization: `Bearer ${process.env.KLANTLY_API_KEY}` },
});
const json = await response.json();
if (!response.ok) throw new Error(json.detail ?? json.title);
return json;
}
// Fetches the work orders completed since `since` and passes them one by one to `handle`.
// Returns the moment to use as `since` next time.
export async function fetchCompletedWorkOrders(since, handle) {
const startedAt = new Date(Date.now() - 60_000).toISOString(); // One minute of margin for clock skew.
let cursor = null;
do {
const params = new URLSearchParams({ 'filter[status]': 'completed', 'filter[updated_since]': since, limit: '100' });
if (cursor) params.set('cursor', cursor);
const page = await klantly(`work-orders?${params}`);
for (const workOrder of page.data) {
await handle(workOrder); // For example, create an invoice in your ERP; deduplicate on workOrder.id.
}
cursor = page.meta.next_cursor;
} while (cursor);
return startedAt;
}Handling errors
422withvalidation_failed: for example a line withoutnameor an unknown field in a line.errorssays per field what is wrong.409withinvalid_state_transition: the work order has already been invoiced.429withrate_limited: wait the number of seconds inRetry-Afterand try again. See Rate limits.
Last updated on September 15, 2026