API reference
Attachments
Files on a quote and photos on a work order: list, upload and delete. Downloads go through a signed link that is valid for fifteen minutes and needs no API key.
Endpoints
-
GET
/quotes/{quote}/attachmentsList attachments -
POST
/quotes/{quote}/attachmentsUpload attachment -
DELETE
/quotes/{quote}/attachments/{attachment}Delete attachment -
GET
/work-orders/{work_order}/photosList work-order photos -
POST
/work-orders/{work_order}/photosUpload work-order photo -
DELETE
/work-orders/{work_order}/photos/{photo}Delete work-order photo
List attachments
/api/v1/quotes/{quote}/attachments
All attachments on this quote, oldest first, each with a signed download link.
- Scope
-
quotes.read— Read quotes, with their lines, amounts and the customer details on them - Required feature
quotes
Path parameters
| Name | Type | Description |
|---|---|---|
quote required |
string (uuid) | The id (UUID) of the quote. |
Example request
curl "https://app.klantly.com/api/v1/quotes/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/attachments" \
-H "Authorization: Bearer $KLANTLY_API_KEY"$client = new \GuzzleHttp\Client([
'base_uri' => 'https://app.klantly.com/api/v1/',
'headers' => ['Authorization' => 'Bearer ' . getenv('KLANTLY_API_KEY')],
]);
$response = $client->request('GET', 'quotes/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/attachments');
$data = json_decode((string) $response->getBody(), true)['data'];const response = await fetch('https://app.klantly.com/api/v1/quotes/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/attachments', {
headers: {
Authorization: `Bearer ${process.env.KLANTLY_API_KEY}`,
},
});
const { data } = await response.json();import os
import requests
response = requests.get(
"https://app.klantly.com/api/v1/quotes/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/attachments",
headers={
"Authorization": f"Bearer {os.environ['KLANTLY_API_KEY']}",
},
)
data = response.json()["data"]Response 200
The response is a list with cursor pagination: data contains the objects, meta the pagination.
{
"data": [
{
"object": "attachment",
"id": "9d3f9e56-af10-4123-b345-e6f7a8b9c0d5",
"quote_id": "9d3f8449-afe1-4a6e-9dbc-c1dfe0f1a2ba",
"name": "technische-tekening.pdf",
"mime_type": "application/pdf",
"size": 248000,
"description": "Tekening van de dakopbouw",
"visible_to_customer": true,
"download_url": "https://app.klantly.com/api/files/quote-attachment/9d3f9e56-af10-4123-b345-e6f7a8b9c0d5?expires=1789000000&signature=8f1c…",
"created_at": "2026-09-14T10:15:00Z"
}
],
"meta": {
"limit": 50,
"next_cursor": "eyJpZCI6IjlkM2Y2YzFlIn0",
"prev_cursor": null
}
}Possible errors
-
403
insufficient_scope— This API key has no access to this action. -
404
not_found— Not found.
Upload attachment
/api/v1/quotes/{quote}/attachments
Adds a file to the quote. Send it as multipart/form-data in the field file — together with the work-order photo this is the only endpoint that does not expect JSON. Allowed are pdf, jpg, jpeg, png, gif, webp, doc, docx, xls and xlsx, up to 10 MB per file and 20 attachments per quote; beyond that you get 403 limit_reached. The file type is checked against the contents, not the name.
- Scope
-
quotes.write— Create and update quotes (drafts only), and accept or reject them on the customer's behalf - Required feature
quotes
Path parameters
| Name | Type | Description |
|---|---|---|
quote required |
string (uuid) | The id (UUID) of the quote. |
Body (JSON)
| Field | Type | Description |
|---|---|---|
file
required
|
string (binary) | The file itself, as multipart/form-data. Up to 10 MB; allowed are pdf, jpg, jpeg, png, gif, webp, doc, docx, xls and xlsx. |
description
optional
|
string | Your own description of the attachment. can be empty (null) · at most 255 characters |
visible_to_customer
optional
|
boolean | True if the customer receives this attachment with the quote. |
Example request
curl -X POST "https://app.klantly.com/api/v1/quotes/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/attachments" \
-H "Authorization: Bearer $KLANTLY_API_KEY"$client = new \GuzzleHttp\Client([
'base_uri' => 'https://app.klantly.com/api/v1/',
'headers' => ['Authorization' => 'Bearer ' . getenv('KLANTLY_API_KEY')],
]);
$response = $client->request('POST', 'quotes/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/attachments');
$data = json_decode((string) $response->getBody(), true)['data'];const response = await fetch('https://app.klantly.com/api/v1/quotes/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/attachments', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.KLANTLY_API_KEY}`,
},
});
const { data } = await response.json();import os
import requests
response = requests.post(
"https://app.klantly.com/api/v1/quotes/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/attachments",
headers={
"Authorization": f"Bearer {os.environ['KLANTLY_API_KEY']}",
},
)
data = response.json()["data"]Response 201
{
"data": {
"object": "attachment",
"id": "9d3f9e56-af10-4123-b345-e6f7a8b9c0d5",
"quote_id": "9d3f8449-afe1-4a6e-9dbc-c1dfe0f1a2ba",
"name": "technische-tekening.pdf",
"mime_type": "application/pdf",
"size": 248000,
"description": "Tekening van de dakopbouw",
"visible_to_customer": true,
"download_url": "https://app.klantly.com/api/files/quote-attachment/9d3f9e56-af10-4123-b345-e6f7a8b9c0d5?expires=1789000000&signature=8f1c…",
"created_at": "2026-09-14T10:15:00Z"
}
}Possible errors
-
403
insufficient_scope— This API key has no access to this action. -
422
validation_failed— The input is invalid. -
422
unknown_field— The input contains an unknown field. -
415
unsupported_media_type— This format is not supported. -
413
payload_too_large— The request body is too large. -
404
not_found— Not found. -
403
limit_reached— The subscription limit has been reached. -
413
payload_too_large— The request body is too large. -
415
unsupported_media_type— This format is not supported.
Delete attachment
/api/v1/quotes/{quote}/attachments/{attachment}
Deletes the attachment and the file itself. This cannot be undone.
- Scope
-
quotes.write— Create and update quotes (drafts only), and accept or reject them on the customer's behalf - Required feature
quotes
Path parameters
| Name | Type | Description |
|---|---|---|
quote required |
string (uuid) | The id (UUID) of the quote. |
attachment required |
string (uuid) | The id (UUID) of the attachment. |
Example request
curl -X DELETE "https://app.klantly.com/api/v1/quotes/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/attachments/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70" \
-H "Authorization: Bearer $KLANTLY_API_KEY"$client = new \GuzzleHttp\Client([
'base_uri' => 'https://app.klantly.com/api/v1/',
'headers' => ['Authorization' => 'Bearer ' . getenv('KLANTLY_API_KEY')],
]);
$response = $client->request('DELETE', 'quotes/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/attachments/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70');
$data = json_decode((string) $response->getBody(), true)['data'];const response = await fetch('https://app.klantly.com/api/v1/quotes/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/attachments/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.KLANTLY_API_KEY}`,
},
});
const { data } = await response.json();import os
import requests
response = requests.delete(
"https://app.klantly.com/api/v1/quotes/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/attachments/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70",
headers={
"Authorization": f"Bearer {os.environ['KLANTLY_API_KEY']}",
},
)
data = response.json()["data"]Response 200
{
"data": {
"object": "note",
"id": "9d3f7b41-2d6f-4e8c-9b3a-4f5d6e7a8b92",
"deleted": true
}
}Possible errors
-
403
insufficient_scope— This API key has no access to this action. -
404
not_found— Not found.
List work-order photos
/api/v1/work-orders/{work_order}/photos
All photos on this work order, in the order they appear on it, each with a signed download link.
- Scope
-
work_orders.read— Read work orders, with the customer's name, address and contact details - Required feature
work_orders
Path parameters
| Name | Type | Description |
|---|---|---|
work_order required |
string (uuid) | The id (UUID) of the work order. |
Example request
curl "https://app.klantly.com/api/v1/work-orders/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/photos" \
-H "Authorization: Bearer $KLANTLY_API_KEY"$client = new \GuzzleHttp\Client([
'base_uri' => 'https://app.klantly.com/api/v1/',
'headers' => ['Authorization' => 'Bearer ' . getenv('KLANTLY_API_KEY')],
]);
$response = $client->request('GET', 'work-orders/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/photos');
$data = json_decode((string) $response->getBody(), true)['data'];const response = await fetch('https://app.klantly.com/api/v1/work-orders/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/photos', {
headers: {
Authorization: `Bearer ${process.env.KLANTLY_API_KEY}`,
},
});
const { data } = await response.json();import os
import requests
response = requests.get(
"https://app.klantly.com/api/v1/work-orders/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/photos",
headers={
"Authorization": f"Bearer {os.environ['KLANTLY_API_KEY']}",
},
)
data = response.json()["data"]Response 200
The response is a list with cursor pagination: data contains the objects, meta the pagination.
{
"data": [
{
"object": "work_order_photo",
"id": "9d3f9f67-b021-4234-c456-f7a8b9c0d1e6",
"work_order_id": "9d3f8328-9ed0-4f5d-8cab-b0cedfe0f1a9",
"kind": "after",
"caption": "Dakgoot na montage",
"download_url": "https://app.klantly.com/api/files/quote-attachment/9d3f9e56-af10-4123-b345-e6f7a8b9c0d5?expires=1789000000&signature=8f1c…",
"created_at": "2026-09-14T10:15:00Z"
}
],
"meta": {
"limit": 50,
"next_cursor": "eyJpZCI6IjlkM2Y2YzFlIn0",
"prev_cursor": null
}
}Possible errors
-
403
insufficient_scope— This API key has no access to this action. -
404
not_found— Not found.
Upload work-order photo
/api/v1/work-orders/{work_order}/photos
Adds a photo to the work order; it also appears in the PDF. Send it as multipart/form-data in the field file. Images only (jpg, jpeg, png, gif, webp), up to 10 MB per photo and 30 photos per work order. Use kind to say whether the photo is from before or after the job.
- Scope
-
work_orders.write— Create and update work orders, and change their status (completing can send a review request) - Required feature
work_orders
Path parameters
| Name | Type | Description |
|---|---|---|
work_order required |
string (uuid) | The id (UUID) of the work order. |
Body (JSON)
| Field | Type | Description |
|---|---|---|
file
required
|
string (binary) | The photo itself, as multipart/form-data. Up to 10 MB; allowed are jpg, jpeg, png, gif and webp. |
kind
optional
|
string | before (before the job), after (afterwards) or other. one of: before, after, other |
caption
optional
|
string | Caption for the photo. can be empty (null) · at most 255 characters |
Example request
curl -X POST "https://app.klantly.com/api/v1/work-orders/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/photos" \
-H "Authorization: Bearer $KLANTLY_API_KEY"$client = new \GuzzleHttp\Client([
'base_uri' => 'https://app.klantly.com/api/v1/',
'headers' => ['Authorization' => 'Bearer ' . getenv('KLANTLY_API_KEY')],
]);
$response = $client->request('POST', 'work-orders/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/photos');
$data = json_decode((string) $response->getBody(), true)['data'];const response = await fetch('https://app.klantly.com/api/v1/work-orders/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/photos', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.KLANTLY_API_KEY}`,
},
});
const { data } = await response.json();import os
import requests
response = requests.post(
"https://app.klantly.com/api/v1/work-orders/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/photos",
headers={
"Authorization": f"Bearer {os.environ['KLANTLY_API_KEY']}",
},
)
data = response.json()["data"]Response 201
{
"data": {
"object": "work_order_photo",
"id": "9d3f9f67-b021-4234-c456-f7a8b9c0d1e6",
"work_order_id": "9d3f8328-9ed0-4f5d-8cab-b0cedfe0f1a9",
"kind": "after",
"caption": "Dakgoot na montage",
"download_url": "https://app.klantly.com/api/files/quote-attachment/9d3f9e56-af10-4123-b345-e6f7a8b9c0d5?expires=1789000000&signature=8f1c…",
"created_at": "2026-09-14T10:15:00Z"
}
}Possible errors
-
403
insufficient_scope— This API key has no access to this action. -
422
validation_failed— The input is invalid. -
422
unknown_field— The input contains an unknown field. -
415
unsupported_media_type— This format is not supported. -
413
payload_too_large— The request body is too large. -
404
not_found— Not found. -
403
limit_reached— The subscription limit has been reached. -
413
payload_too_large— The request body is too large. -
415
unsupported_media_type— This format is not supported.
Delete work-order photo
/api/v1/work-orders/{work_order}/photos/{photo}
Deletes the photo and the file itself. This cannot be undone.
- Scope
-
work_orders.write— Create and update work orders, and change their status (completing can send a review request) - Required feature
work_orders
Path parameters
| Name | Type | Description |
|---|---|---|
work_order required |
string (uuid) | The id (UUID) of the work order. |
photo required |
string (uuid) | The id (UUID) of the photo. |
Example request
curl -X DELETE "https://app.klantly.com/api/v1/work-orders/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/photos/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70" \
-H "Authorization: Bearer $KLANTLY_API_KEY"$client = new \GuzzleHttp\Client([
'base_uri' => 'https://app.klantly.com/api/v1/',
'headers' => ['Authorization' => 'Bearer ' . getenv('KLANTLY_API_KEY')],
]);
$response = $client->request('DELETE', 'work-orders/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/photos/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70');
$data = json_decode((string) $response->getBody(), true)['data'];const response = await fetch('https://app.klantly.com/api/v1/work-orders/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/photos/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.KLANTLY_API_KEY}`,
},
});
const { data } = await response.json();import os
import requests
response = requests.delete(
"https://app.klantly.com/api/v1/work-orders/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70/photos/9d3f6c1e-4b2a-4c8e-9f1a-2b3c4d5e6f70",
headers={
"Authorization": f"Bearer {os.environ['KLANTLY_API_KEY']}",
},
)
data = response.json()["data"]Response 200
{
"data": {
"object": "note",
"id": "9d3f7b41-2d6f-4e8c-9b3a-4f5d6e7a8b92",
"deleted": true
}
}Possible errors
-
403
insufficient_scope— This API key has no access to this action. -
404
not_found— Not found.
The object
All fields are always present; a field without a value is null.
| Field | Type | Description |
|---|---|---|
object |
string | Always "attachment". |
id |
string (uuid) | Id of the attachment. |
quote_id |
string (uuid) | The quote this attachment belongs to. |
name |
string | The file name as it was uploaded. can be empty (null) |
mime_type |
string | The file type, such as application/pdf. can be empty (null) |
size |
integer | The size in bytes. can be empty (null) |
description |
string | Your own description of the attachment. can be empty (null) |
visible_to_customer |
boolean | True if the customer receives this attachment with the quote. |
download_url |
string (uri) | A signed link to the file, valid for fifteen minutes and openable without an API key. Request it again once it has expired. |
created_at |
string (date-time) | When the attachment was added (UTC). |