Repair Angel API v2
RESTful JSON API for integrating with the Repair Angel repair management platform.
Introduction
https://yourdomain.com/repair-angel-v2/api
All requests must include the following headers:
// Install: composer require guzzlehttp/guzzle use GuzzleHttp\Client; $client = new Client([ 'base_uri' => 'https://yourdomain.com/repair-angel-v2/api/', 'headers' => [ 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $token, ], ]);
# Install: pip install requests import requests BASE = 'https://yourdomain.com/repair-angel-v2/api' headers = { 'Accept': 'application/json', 'Authorization': f'Bearer {token}', }
const BASE = 'https://yourdomain.com/repair-angel-v2/api'; const headers = { 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, };
Authentication
Returns a bearer token. Pass this token in the Authorization header for all subsequent requests.
| Field | Type | Description |
|---|---|---|
| email * | string | User email address |
| password * | string | User password |
$res = $client->post('auth/login', [ 'json' => [ 'email' => 'admin@example.com', 'password' => 'secret', ], ]); $token = json_decode($res->getBody(), true)['token'];
r = requests.post(f'{BASE}/auth/login', json={ 'email': 'admin@example.com', 'password': 'secret', }) token = r.json()['token']
const res = await fetch(`${BASE}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'admin@example.com', password: 'secret' }), }); const { token } = await res.json();
Response 200:
{
"token": "1|abc123...",
"user": { "id": 1, "name": "Admin", "email": "admin@example.com" }
}Revokes the current bearer token.
{ "message": "Logged out" }Returns the authenticated user's profile.
{
"id": 1, "name": "John Doe", "email": "john@example.com",
"role": "admin", "company_id": 5
}Error Codes
| Code | Meaning | Common Cause |
|---|---|---|
| 200 | OK | Successful GET / PATCH |
| 201 | Created | Successful POST (resource created) |
| 204 | No Content | Successful DELETE |
| 401 | Unauthorized | Missing or invalid token |
| 403 | Forbidden | Insufficient permission |
| 404 | Not Found | Resource ID does not exist |
| 422 | Validation Error | Invalid / missing required fields |
| 500 | Server Error | Unexpected server-side error |
// 422 response example { "message": "The given data was invalid.", "errors": { "email": ["The email field is required."] } }
Pagination
{
"data": [...],
"current_page": 1,
"last_page": 4,
"per_page": 50,
"total": 187
}Work Orders
| Query Param | Type | Description |
|---|---|---|
| status | string | Filter by status slug |
| customer_id | integer | Filter by customer |
| assigned_to | integer | Filter by employee |
| q | string | Full-text search (ticket no, customer, device) |
| page | integer | Page number |
$res = $client->get('work-orders', [ 'query' => ['status' => 'in_repair', 'page' => 1], ]); $orders = json_decode($res->getBody(), true);
r = requests.get(f'{BASE}/work-orders', params={'status': 'in_repair'}, headers=headers) orders = r.json()
const res = await fetch(`${BASE}/work-orders?status=in_repair`, { headers }); const orders = await res.json();
| Field | Type | Description |
|---|---|---|
| customer_id * | integer | Customer ID |
| device_brand | string | Brand/make of device |
| device_model | string | Model of device |
| problem_description | string | Reported fault description |
| priority | string | normal | high | urgent |
| assigned_to | integer | Employee ID |
| expected_ready_at | datetime | Estimated completion date |
$res = $client->post('work-orders', ['json' => [ 'customer_id' => 42, 'device_brand' => 'Apple', 'device_model' => 'iPhone 15 Pro', 'problem_description' => 'Cracked screen', 'priority' => 'normal', ]]);
r = requests.post(f'{BASE}/work-orders', headers=headers, json={ 'customer_id': 42, 'device_brand': 'Apple', 'device_model': 'iPhone 15 Pro', 'problem_description': 'Cracked screen', })
const res = await fetch(`${BASE}/work-orders`, { method: 'POST', headers, body: JSON.stringify({ customer_id: 42, device_brand: 'Apple', device_model: 'iPhone 15 Pro', problem_description: 'Cracked screen', }), });
{
"id": 101, "ticket_no": "WO-2026-0101",
"customer": { "id": 42, "name": "Jane Smith" },
"device_brand": "Apple", "device_model": "iPhone 15 Pro",
"status": { "slug": "in_repair", "label": "In Repair" },
"lines": [], "notes": [], "created_at": "2026-03-27T10:00:00Z"
}| Field | Type | Description |
|---|---|---|
| description * | string | Line description |
| product_id | integer | Optional linked product |
| quantity | number | Quantity (default 1) |
| unit_price * | number | Price per unit excl. VAT |
| vat_rate | number | VAT % (default 21) |
| Field | Type | Description |
|---|---|---|
| note * | string | Note content |
| is_internal | boolean | Hide from customer (default true) |
Customers
| Query | Type | Description |
|---|---|---|
| q | string | Search by name, email, or phone |
| company_id | integer | Filter by company |
| Field | Type | Description |
|---|---|---|
| first_name * | string | |
| last_name | string | |
| string | ||
| phone | string | |
| address | string | |
| city | string | |
| postal_code | string | |
| company_id | integer | Link to company |
Same fields as POST. All fields optional.
Returns 204 No Content on success.
Invoices
| Query | Type | Description |
|---|---|---|
| status | string | draft | sent | paid | overdue | cancelled |
| customer_id | integer | |
| from | date | Invoice date from |
| to | date | Invoice date to |
| Field | Type | Description |
|---|---|---|
| customer_id * | integer | |
| work_order_id | integer | Link to work order |
| invoice_date * | date | |
| due_date | date | Payment due date |
| lines | array | Array of line objects {description, qty, unit_price, vat_rate} |
$res = $client->post('invoices', ['json' => [ 'customer_id' => 42, 'invoice_date' => '2026-03-27', 'due_date' => '2026-04-27', 'lines' => [[ 'description' => 'Screen replacement', 'qty' => 1, 'unit_price' => 89.00, 'vat_rate' => 21, ]], ]]);
r = requests.post(f'{BASE}/invoices', headers=headers, json={ 'customer_id': 42, 'invoice_date': '2026-03-27', 'lines': [{ 'description': 'Screen replacement', 'qty': 1, 'unit_price': 89.00, 'vat_rate': 21 }], })
await fetch(`${BASE}/invoices`, { method: 'POST', headers, body: JSON.stringify({ customer_id: 42, invoice_date: '2026-03-27', lines: [{ description: 'Screen replacement', qty: 1, unit_price: 89, vat_rate: 21 }], }), });
Products
| Query | Type | Description |
|---|---|---|
| q | string | Search by name or SKU |
| category_id | integer | |
| in_stock | boolean | Only products with stock > 0 |
| Field | Type | Description |
|---|---|---|
| name * | string | |
| sku | string | Stock keeping unit |
| price_excl * | number | Selling price excl. VAT |
| purchase_price | number | Cost price |
| vat_rate | number | Default 21 |
| stock_quantity | integer | Initial stock |
| min_stock | integer | Reorder threshold |
Stock
[
{ "product_id": 1, "sku": "SCR-IP15-BLK", "name": "iPhone 15 Screen", "quantity": 12, "min_stock": 3 },
...
]| Field | Type | Description |
|---|---|---|
| product_id * | integer | |
| quantity * | integer | Positive (add) or negative (remove) |
| reason | string | Adjustment reason note |
Warehouse (WMS)
[ { "product_id": 5, "sku": "CAB-USB-C", "quantity": 30, "bin": "A-01-02" }, ... ]Receiving
| Field | Type | Description |
|---|---|---|
| lines * | array | [{po_line_id, received_qty}] |
| Field | Type | Description |
|---|---|---|
| product_id * | integer | |
| quantity * | integer | |
| supplier_id | integer | |
| reference | string | Delivery note / reference |
Pick Lists
| Field | Type | Description |
|---|---|---|
| work_order_id * | integer |
Marks the pick list as done and deducts stock from bins.
General Ledger
[ { "id": 1, "code": "1000", "name": "Kas", "type": "asset" }, ... ]| Field | Type | Description |
|---|---|---|
| date * | date | Booking date |
| description | string | |
| lines * | array | [{account_id, debit, credit, description}] |
BTW Aangifte
[ { "id": 1, "year": 2026, "quarter": 1, "status": "open", "total_vat": 4821.00 } ]Marks the period as filed. No further entries can be posted.
Employees
[ { "id": 1, "name": "Alice B.", "role": "Technician", "email": "alice@shop.nl" }, ... ]Leave Management
| Field | Type | Description |
|---|---|---|
| employee_id * | integer | |
| leave_type_id * | integer | |
| start_date * | date | |
| end_date * | date | |
| notes | string |
License Management
{
"id": 1,
"license_type": "SMR",
"tier": "growth",
"status": "active",
"valid_until": "2027-03-27",
"max_users": 15,
"price_monthly": "149.00",
"modules": [
{ "module_key": "work_orders", "is_active": true },
{ "module_key": "invoices", "is_active": true },
...
]
}| Field | Type | Description |
|---|---|---|
| license_type * | string | SMR | AUTO | AVIATION | MARINE |
| tier * | string | starter | growth | enterprise |
| valid_from * | date | |
| valid_until | date | |
| billing_cycle | string | monthly | annual |
| company_id | integer | Link to company |
Changes status from trial to active.
| Field | Type | Description |
|---|---|---|
| tier * | string | starter | growth | enterprise |
License Modules
[
{ "module_key": "work_orders", "available": true, "is_active": true, "activated_at": "2026-01-01T00:00:00Z" },
{ "module_key": "accounting", "available": true, "is_active": false, "activated_at": null },
...
]| Field | Type | Description |
|---|---|---|
| module_key * | string | e.g. accounting, hrm, warehouse |
{ "has_module": true }UPS Shipping
| Field | Type | Description |
|---|---|---|
| ship_to_name * | string | |
| ship_to_address * | string | |
| ship_to_city * | string | |
| ship_to_postal * | string | |
| ship_to_country | string | ISO2, default NL |
| work_order_id | integer | Links label to work order |
| is_return | boolean | Return label |
| weight | number | Package weight in kg |
{ "status": "In Transit", "activities": [ { "location": "Amsterdam", "description": "Departed facility", "date": "2026-03-27" } ] }VIES VAT Check
| Field | Type | Description |
|---|---|---|
| vat_number * | string | e.g. NL123456789B01 |
| country_code | string | ISO2, auto-detected if omitted |
| invoice_id | integer | Log result against invoice |
{ "is_valid": true, "name": "ACME BV", "vat_number": "NL123456789B01", "country_code": "NL" }Global Search
{
"customers": [ { "id": 42, "name": "Jane Smith" } ],
"work_orders": [ { "id": 101, "ticket_no": "WO-2026-0101" } ],
"products": [],
"invoices": []
}API Tokens
| Field | Type | Description |
|---|---|---|
| name * | string | Token label (e.g. "Zapier Integration") |
| abilities | array | Permission scopes (default ["*"]) |
{ "token": "5|xKqz9...", "name": "Zapier Integration" }Immediately revokes the token. Returns 204.
Notification Center
/api/v1/notificationsReturns paginated notifications for the authenticated user.
| Query Param | Type | Description |
|---|---|---|
| unread | integer | Pass 1 to return only unread notifications |
| type | string | Filter by type: work_order | invoice | task | sla | customer | system |
| page | integer | Page number |
{
"data": [
{
"id": 55,
"type": "work_order",
"title": "WO-2026-0055 status changed",
"body": "Work order moved to In Repair",
"read_at": null,
"created_at": "2026-03-27T09:12:00Z"
}
],
"current_page": 1, "last_page": 3, "total": 112
}Marks a specific notification as read. Returns 200 with updated notification.
{ "message": "All notifications marked as read", "count": 34 }Permanently deletes the notification. Returns 204 No Content.
{ "count": 7 }Returns the user's per-event notification channel preferences.
{
"work_orders": [
{ "key": "wo_status_changed", "label": "Status changed", "channels": ["in_app", "email"] },
{ "key": "wo_assigned", "label": "Engineer assigned", "channels": ["in_app"] }
],
"invoices": [ { "key": "invoice_overdue", "label": "Invoice overdue", "channels": ["in_app", "email", "push"] } ],
"tasks": [...],
"sla": [...],
"system": [...]
}| Field | Type | Description |
|---|---|---|
| preferences * | object | Map of event key → array of channels (in_app, email, push) |
// Request body { "preferences": { "wo_status_changed": ["in_app", "email"], "invoice_overdue": ["in_app", "push"] } }
| Field | Type | Description |
|---|---|---|
| channel * | string | in_app | email | push |
{ "message": "Test notification sent via in_app" }Tutorial System
/api/v1/tutorialsReturns tutorials available to the current user's role. Admins receive all tutorials.
[
{ "id": 1, "title": "Getting Started", "role": "all", "total_steps": 5 },
{ "id": 2, "title": "Creating a Work Order", "role": "technician", "total_steps": 7 }
]{
"id": 1, "title": "Getting Started",
"steps": [
{ "id": 1, "order": 1, "title": "Welcome", "body": "...", "completed": true },
{ "id": 2, "order": 2, "title": "Dashboard overview", "body": "...", "completed": false }
]
}[
{
"tutorial": { "id": 1, "title": "Getting Started" },
"total_steps": 5, "completed_steps": 3,
"percent": 60, "completed": false
}
]| Field | Type | Description |
|---|---|---|
| tutorial_id * | integer | Tutorial ID |
| step_id * | integer | Step ID to mark complete |
{ "message": "Step marked complete", "percent": 40, "completed": false }| Field | Type | Description |
|---|---|---|
| step_id * | integer | Step ID to mark complete |
Clears all completed steps for the given tutorial for the authenticated user.
{ "message": "Tutorial progress reset" }{
"tutorials_enabled": true,
"completed_onboarding": false,
"dismissed_hints": ["dashboard_hint", "wo_hint"]
}| Field | Type | Description |
|---|---|---|
| tutorials_enabled | boolean | Enable or disable the tutorial system |
| completed_onboarding | boolean | Mark onboarding as finished |
| dismissed_hints | array | Array of hint keys to dismiss |
{ "message": "14 tutorials seeded" }Work Order Timeline / Audit
/api/v1/work-orders/{id}/timeline| Query Param | Type | Description |
|---|---|---|
| event | string | Filter by event type: created | field_changed | note_added | engineer_assigned | status_changed | completed |
| page | integer | Page number |
{
"data": [
{
"id": 301,
"event": "status_changed",
"description": "Status changed from New to In Repair",
"user": { "id": 3, "name": "Jan Bakker" },
"created_at": "2026-03-27T08:45:00Z"
}
],
"current_page": 1, "last_page": 2, "total": 18
}| Field | Type | Description |
|---|---|---|
| note * | string | Internal note text (not visible to customer) |
{
"id": 302, "event": "note_added",
"description": "Waiting for spare part from supplier.",
"user": { "id": 3, "name": "Jan Bakker" },
"created_at": "2026-03-27T09:00:00Z"
}Quotations
/api/v1/quotations| Query Param | Type | Description |
|---|---|---|
| status | string | draft | sent | accepted | rejected | expired |
| page | integer | Page number |
| Field | Type | Description |
|---|---|---|
| customer_id * | integer | Customer ID |
| work_order_id | integer | Associated work order (optional) |
| valid_until | date | Expiry date (YYYY-MM-DD) |
| lines * | array | Array of line items [{description, qty, unit_price, vat_pct}] |
| notes | string | Customer-facing notes |
// Request body { "customer_id": 42, "valid_until": "2026-04-30", "lines": [ { "description": "Screen replacement", "qty": 1, "unit_price": 120.00, "vat_pct": 21 } ] }
Returns quotation with full line items and customer info.
Update quotation fields. Only draft quotations may be fully edited; sent quotations allow notes-only updates.
Deletes the quotation. Only draft quotations can be deleted. Returns 204.
Sends the quotation PDF via email to the customer and sets status to sent.
{ "message": "Quotation sent", "status": "sent" }Creates an invoice from the accepted quotation lines and marks the quotation as accepted.
{ "invoice_id": 78, "message": "Invoice created from quotation" }{ "work_order_id": 203, "message": "Work order created from quotation" }Tasks
/api/v1/tasks| Query Param | Type | Description |
|---|---|---|
| status | string | open | in_progress | done | cancelled |
| assigned_to | integer | Filter by assignee employee ID |
| page | integer | Page number |
| Field | Type | Description |
|---|---|---|
| title * | string | Task title |
| description | string | Task details |
| assigned_to | integer | Employee ID |
| due_date | date | Due date (YYYY-MM-DD) |
| status | string | Initial status (default: open) |
| work_order_id | integer | Link to work order (optional) |
Update any task field. Partial updates supported.
Permanently deletes the task. Returns 204.
Returns tasks grouped by status for Kanban board rendering.
[
{ "status": "open", "tasks": [{ "id": 10, "title": "Order spare parts" }] },
{ "status": "in_progress", "tasks": [{ "id": 11, "title": "Call customer" }] },
{ "status": "done", "tasks": [] }
]| Field | Type | Description |
|---|---|---|
| task_id * | integer | Task to move |
| position * | integer | New sort position within column |
| status * | string | Target column status |
{ "message": "Task reordered" }Automation Rules
/api/v1/automation-rules[
{
"id": 1, "name": "Auto-close after 30 days",
"trigger": "wo_idle", "active": true,
"last_run_at": "2026-03-26T23:00:00Z"
}
]| Field | Type | Description |
|---|---|---|
| name * | string | Descriptive rule name |
| trigger * | string | Event that triggers the rule |
| conditions | array | Array of condition objects [{field, operator, value}] |
| actions * | array | Array of action objects [{type, params}] |
| active | boolean | Enable rule immediately (default: true) |
Update rule definition. Partial updates supported.
Permanently deletes the rule. Returns 204.
{ "active": false, "message": "Rule deactivated" }Paginated execution history for the rule, including outcome and any errors.
{
"data": [
{ "ran_at": "2026-03-26T23:00:00Z", "outcome": "success", "affected_records": 3 }
]
}Business Intelligence
/api/v1/bi| Query Param | Type | Description |
|---|---|---|
| period | string | 7d | 30d | 90d | jaar (default: 30d) |
{
"period": "30d",
"data": [
{ "date": "2026-02-25", "amount": 1240.50 },
{ "date": "2026-02-26", "amount": 890.00 }
],
"total": 38450.75,
"growth_pct": 12.4
}{
"status_distribution": { "new": 12, "in_repair": 34, "completed": 198 },
"avg_duration_hours": 18.4,
"completion_rate_pct": 94.2
}[
{ "engineer_id": 3, "name": "Jan Bakker", "completed": 45, "avg_hours": 16.2, "revenue": 9800.00 }
]{
"new_customers": 14, "returning_customers": 63,
"avg_ltv": 420.50
}{
"top_parts": [
{ "product_id": 7, "name": "iPhone 14 Screen", "used": 28 }
],
"inventory_value": 14250.00
}{
"outstanding_amount": 8420.00,
"overdue_amount": 1240.00,
"avg_payment_days": 11.3
}Warranty
/api/v1/warrantyReturns paginated warranty claims with status and linked work order information.
| Field | Type | Description |
|---|---|---|
| work_order_id * | integer | Original work order |
| description * | string | Defect description |
| claimed_at | date | Date of claim (default: today) |
{
"id": 15, "status": "pending",
"work_order_id": 101, "created_at": "2026-03-27T10:00:00Z"
}Update the description or metadata of a pending claim.
Approves the claim and automatically creates a warranty work order.
{ "message": "Claim approved", "warranty_work_order_id": 250 }| Field | Type | Description |
|---|---|---|
| reason * | string | Rejection reason communicated to customer |
{ "message": "Claim rejected", "status": "rejected" }Returns defined warranty policies (e.g. duration per repair type).
| Field | Type | Description |
|---|---|---|
| name * | string | Policy name |
| duration_days * | integer | Warranty period in days |
| applies_to | string | Work order type / category |
Update warranty policy fields. Partial updates supported.
SLA
/api/v1/slaReturns paginated list of work orders that have breached their SLA.
{
"data": [
{ "work_order_id": 88, "sla_config": "Standard 24h", "breached_at": "2026-03-26T14:00:00Z", "overdue_hours": 6.5 }
]
}Returns all defined SLA policies.
| Field | Type | Description |
|---|---|---|
| name * | string | Policy name |
| response_hours * | integer | Time to first response (hours) |
| resolution_hours * | integer | Time to resolution (hours) |
| applies_to | string | Work order priority / category |
Update SLA policy fields. Partial updates supported.
Runs the SLA breach check immediately (normally runs via scheduler). Useful for debugging.
{ "message": "SLA check completed", "new_breaches": 2 }Credit Notes
/api/v1/credit-notesReturns paginated credit notes.
| Field | Type | Description |
|---|---|---|
| invoice_id * | integer | Invoice being credited |
| reason * | string | Reason for credit |
| lines * | array | Line items to credit [{description, qty, unit_price, vat_pct}] |
{
"id": 9, "credit_note_number": "CN-2026-0009",
"total": -48.40, "status": "open"
}Returns credit note with full line items and linked invoice details.
Update reason or lines on an unapplied credit note.
Deletes an unapplied credit note. Returns 204.
Applies the credit note balance against the linked invoice, reducing its outstanding amount.
{ "message": "Credit applied", "invoice_remaining": 71.60 }Recurring Invoices
/api/v1/recurring-invoicesReturns all recurring invoice schedules with next run date.
| Field | Type | Description |
|---|---|---|
| customer_id * | integer | Customer ID |
| frequency * | string | weekly | monthly | quarterly | yearly |
| next_run_date * | date | First invoice generation date |
| lines * | array | Invoice line items |
| auto_send | boolean | Automatically email invoice on generation (default: false) |
Update schedule frequency, lines, or auto-send setting.
Cancels and deletes the recurring schedule. Existing generated invoices are not affected. Returns 204.
Immediately generates an invoice from the recurring template without waiting for the next scheduled run.
{ "invoice_id": 134, "message": "Invoice generated" }Branches
/api/v1/branches[
{ "id": 1, "name": "Amsterdam HQ", "address": "Damrak 1, Amsterdam", "active": true },
{ "id": 2, "name": "Rotterdam", "address": "Coolsingel 10, Rotterdam", "active": true }
]| Field | Type | Description |
|---|---|---|
| name * | string | Branch name |
| address | string | Physical address |
| phone | string | Branch phone number |
| string | Branch email address |
Update branch name, address, contact details, or active state.
Soft-deletes the branch. Returns 204.
3CX Integration
/api/v1/3cx{
"connected": true,
"calls_today": 38,
"missed_today": 4,
"avg_duration_sec": 187,
"active_calls": 2
}{
"api_url": "https://pbx.example.com",
"api_key": "••••••••",
"extension_map": { "3": "100", "5": "101" }
}| Field | Type | Description |
|---|---|---|
| api_url | string | 3CX API base URL |
| api_key | string | 3CX API key |
Returns paginated call log with caller, callee, duration, and outcome.
{
"data": [
{ "id": 4501, "caller": "+31612345678", "extension": "100", "duration_sec": 243, "outcome": "answered", "started_at": "2026-03-27T09:05:00Z" }
]
}[
{ "caller": "+31687654321", "waiting_sec": 45, "position": 1 }
][
{ "user_id": 3, "name": "Jan Bakker", "extension": "100" }
]| Field | Type | Description |
|---|---|---|
| user_id * | integer | Employee user ID |
| extension * | string | 3CX extension number |
SMS System
/api/v1/sms{
"today": 24,
"this_month": 412,
"delivery_rate": 98.3,
"cost_this_month": 8.24
}Returns paginated SMS message history including delivery status.
| Field | Type | Description |
|---|---|---|
| phone * | string | Recipient phone number (E.164 format) |
| body * | string | Message text (max 160 chars for 1 credit) |
| template_id | integer | Use a saved template (overrides body) |
// Request body { "phone": "+31612345678", "body": "Your repair is ready for pickup!" } // Response 200 { "message_id": "sms_9x2k", "status": "queued" }
[
{ "id": 1, "name": "Ready for pickup", "body": "Your repair is ready. Please collect at your convenience." }
]| Field | Type | Description |
|---|---|---|
| name * | string | Template label |
| body * | string | Template text (supports {{customer_name}} placeholders) |
Update template name or body.
Permanently deletes the template. Returns 204.
| Field | Type | Description |
|---|---|---|
| body * | string | Message text or template body |
| tag_id | integer | Send to all customers with this tag |
| is_business | boolean | Send only to business customers |
{ "queued": 84, "message": "84 SMS messages queued for delivery" }Email System
/api/v1/emailReturns paginated inbound email threads, newest first.
Returns paginated outbound email threads.
{
"host": "smtp.mailgun.org",
"port": 587,
"encryption": "tls",
"username": "postmaster@mg.example.com",
"from_name": "Repair Angel",
"from_address": "noreply@example.com"
}| Field | Type | Description |
|---|---|---|
| host * | string | SMTP server hostname |
| port * | integer | SMTP port (e.g. 587) |
| encryption | string | tls | ssl | none |
| username * | string | SMTP username |
| password | string | SMTP password (omit to keep existing) |
| from_name | string | Sender display name |
| from_address | string | Sender email address |
Returns all email threads (inbound and outbound) with optional filtering by customer, date range, or subject.
{
"id": 77, "subject": "Re: Your repair status",
"customer": { "id": 42, "name": "Jane Smith" },
"messages": [
{ "id": 1, "direction": "outbound", "body_html": "<p>Your iPhone is ready...</p>", "sent_at": "2026-03-27T08:00:00Z" },
{ "id": 2, "direction": "inbound", "body_html": "<p>Thanks, I'll pick it up at 5pm.</p>", "sent_at": "2026-03-27T10:30:00Z" }
]
}| Field | Type | Description |
|---|---|---|
| to * | string | Recipient email address |
| subject * | string | Email subject line |
| body_html * | string | HTML email body |
| template_id | integer | Use a saved template (overrides body_html) |
// Request body { "to": "customer@example.com", "subject": "Your repair is ready", "body_html": "<p>Good news! Your device is ready for pickup.</p>" } // Response 200 { "thread_id": 78, "message_id": "msg_abc123", "status": "sent" }
[
{ "id": 1, "name": "Repair Ready", "subject": "Your repair is ready", "updated_at": "2026-01-15" }
]| Field | Type | Description |
|---|---|---|
| name * | string | Template label |
| subject * | string | Default subject line |
| body_html * | string | HTML template body (supports {{customer_name}} placeholders) |
Update template name, subject, or body. Partial updates supported.
Permanently deletes the email template. Returns 204.
AI Repair Assistant
/api/aiSubmits device symptoms to the AI engine and returns ranked repair suggestions with estimated prices. Requires the OpenAI integration to be enabled.
| Field | Type | Description |
|---|---|---|
| device_brand * | string | Device manufacturer (e.g. Apple) |
| device_model * | string | Device model name (e.g. iPhone 14 Pro) |
| symptoms * | string | Free-text description of reported symptoms |
| customer_type * | string | individual or business — affects pricing context |
// Request body { "device_brand": "Apple", "device_model": "iPhone 14 Pro", "symptoms": "Screen cracked, touch unresponsive in bottom half", "customer_type": "individual" } // Response 200 { "suggestions": [ { "name": "Screen replacement", "confidence": "high", "estimated_price": 149.00, "description": "Full OLED panel replacement including digitizer", "requires_diagnosis": false }, { "name": "Digitizer calibration", "confidence": "medium", "estimated_price": 45.00, "description": "Software-level touch recalibration before hardware swap", "requires_diagnosis": true } ], "advice": "Recommend screen replacement as primary fix. Run diagnostics first to rule out logic board damage.", "not_repairable": false }
Returns the current AI engine configuration including the OpenAI model in use, diagnosis pricing, and suggestion limits.
{
"enabled": true,
"openai_model": "gpt-4o",
"diagnosis_cost": 25.00,
"diagnosis_tax_rate_id": 3,
"max_suggestions": 5
}Updates the AI configuration. Body fields are the same as the GET response. All fields are optional — only supplied fields are updated.
| Field | Type | Description |
|---|---|---|
| enabled | boolean | Enable or disable the AI assistant globally |
| openai_model | string | OpenAI model identifier (e.g. gpt-4o) |
| diagnosis_cost | number | Cost charged for a physical diagnosis session |
| diagnosis_tax_rate_id | integer | Tax rate ID applied to the diagnosis cost |
| max_suggestions | integer | Maximum number of suggestions to return (1–10) |
Tax Rates
/api/tax-ratesReturns all configured tax rates ordered by rate ascending.
[
{ "id": 1, "name": "Standard 21%", "rate": 21.00, "description": "Standard Dutch BTW", "is_default": true },
{ "id": 2, "name": "Low 9%", "rate": 9.00, "description": "Reduced rate", "is_default": false },
{ "id": 3, "name": "Zero 0%", "rate": 0.00, "description": "Exempt", "is_default": false }
]| Field | Type | Description |
|---|---|---|
| name * | string | Display label (e.g. Standard 21%) |
| rate * | number | Percentage value (e.g. 21.00) |
| description | string | Optional notes about when to apply this rate |
| is_default | boolean | If true, unsets the current default first |
// Response 201 { "id": 4, "name": "Margin scheme", "rate": 9.00, "description": "Used-goods margin", "is_default": false }
Returns a single tax rate record by ID. Returns 404 if not found.
Updates the name, rate, description, or default flag. Partial updates supported — only provided fields are changed.
Permanently deletes the tax rate. Returns 204 on success. Returns 422 if the tax rate is currently assigned to one or more products or invoice lines.
// 422 — tax rate in use { "message": "Cannot delete: tax rate is assigned to 12 product(s)." }
Marks this tax rate as the system default. All other rates have their is_default flag unset atomically in the same transaction.
// Response 200 { "id": 1, "name": "Standard 21%", "is_default": true }
Pricing & Price Agreements
/api/pricingReturns the resolved price for a given customer and service combination. Considers active price agreements first, then business discount, then base price.
| Query param | Type | Description |
|---|---|---|
| customer_id * | integer | Customer ID to price-check for |
| service_id * | integer | Service / product ID to look up |
// Response 200 { "has_agreement": true, "agreement": { "id": 12, "agreed_price": 89.00, "valid_until": "2026-12-31" }, "business_discount_pct": 10, "recommended_price": 89.00, "base_price": 110.00, "discount_amount": 21.00 }
Returns a paginated list of price agreements. Supports filtering by customer, status, and free-text search.
| Query param | Type | Description |
|---|---|---|
| customer_id | integer | Filter by customer |
| status | string | draft, active, expired, or signed |
| search | string | Full-text search on customer name or service name |
| Field | Type | Description |
|---|---|---|
| customer_id * | integer | Customer the agreement applies to |
| service_id * | integer | Service or product being priced |
| agreed_price * | number | Fixed agreed price excluding VAT |
| valid_from * | date | Start date (YYYY-MM-DD) |
| valid_until | date | Expiry date — omit for open-ended agreements |
| notes | string | Internal notes for this agreement |
// Response 201 { "id": 13, "status": "draft", "customer_id": 42, "service_id": 7, "agreed_price": 89.00, "valid_from": "2026-04-01", "valid_until": null }
Returns a single price agreement with its full details including any attached signature and contract PDF URL.
Updates a draft agreement. Signed or expired agreements cannot be modified — returns 422.
Permanently deletes a draft agreement. Returns 204. Active or signed agreements cannot be deleted — returns 422.
Transitions the agreement from draft to active. The agreement will immediately be considered when resolving prices for the customer.
{ "id": 13, "status": "active", "activated_at": "2026-03-27T09:00:00Z" }Attaches a base64-encoded PNG signature image to the agreement and transitions the status to signed. Typically called from the customer-facing signing screen.
| Field | Type | Description |
|---|---|---|
| signature_data * | string | Base64-encoded PNG of the customer signature |
{ "id": 13, "status": "signed", "signed_at": "2026-03-27T10:15:00Z" }Returns the generated contract PDF as a binary stream (Content-Type: application/pdf). Returns 404 if no contract has been generated yet.
Renders and stores the contract PDF from the current agreement data and signature. Idempotent — regenerates the PDF if called again after changes.
{ "id": 13, "contract_url": "/api/pricing/agreements/13/contract", "generated_at": "2026-03-27T10:20:00Z" }Integration Configuration Hub
/api/integrations. Secret values are always masked as ***masked*** in GET responses.mollie · twilio · snelstart · moneybird · 3cx · ups · openai · outlook · google_workspace · mailpit · stripe · postmark · dhl
Returns all 13 integrations with their enabled status, slug, and a masked config object.
[
{
"slug": "mollie",
"name": "Mollie Payments",
"is_enabled": true,
"config": { "api_key": "***masked***" }
},
{
"slug": "openai",
"name": "OpenAI",
"is_enabled": true,
"config": { "api_key": "***masked***", "organisation_id": "***masked***" }
}
// ... 11 more
]Returns the configuration for a single integration by slug. Secret fields (API keys, passwords, tokens) are always returned as ***masked***. Returns 404 for unknown slugs.
// GET /api/integrations/stripe { "slug": "stripe", "name": "Stripe", "is_enabled": false, "config": { "publishable_key": "pk_test_***masked***", "secret_key": "***masked***", "webhook_secret": "***masked***" } }
Updates the config keys and/or enabled state for an integration. Only keys provided in config are updated — omitted keys are left unchanged. Sending a masked value (***masked***) for an existing secret leaves it unchanged.
| Field | Type | Description |
|---|---|---|
| config | object | Key/value pairs specific to the integration (see slug docs) |
| is_enabled | boolean | Enable or disable the integration |
// Request — enable Mollie and set API key { "is_enabled": true, "config": { "api_key": "live_xxxxxxxxxxxxxxxx" } } // Response 200 { "slug": "mollie", "is_enabled": true, "updated_at": "2026-03-27T11:00:00Z" }
Performs a live connectivity check against the third-party service using the saved credentials. Returns a success flag and human-readable message.
// Response 200 — success { "success": true, "message": "Connected to Mollie — account: RepairAngel BV", "tested_at": "2026-03-27T11:05:22Z" } // Response 200 — failure (still HTTP 200, check "success") { "success": false, "message": "Authentication failed: invalid API key", "tested_at": "2026-03-27T11:05:22Z" }
Employee Phone & Email Config
/api/employees/{id}Returns the telephony configuration for the given employee. SIP password is always returned masked.
{
"employee_id": 5,
"provider": "3cx",
"extension": "101",
"mobile_number": "+31612345678",
"forwarding_number": "+31201234567",
"sip_username": "john.doe@pbx.example.com",
"sip_password": "***masked***",
"is_active": true
}Creates or replaces the employee's telephony configuration. All fields are optional — only supplied fields are updated.
| Field | Type | Description |
|---|---|---|
| provider | string | Telephony provider (e.g. 3cx, twilio) |
| extension | string | Internal PBX extension number |
| mobile_number | string | Employee's mobile number in E.164 format |
| forwarding_number | string | External call-forwarding number in E.164 format |
| sip_username | string | SIP account username / URI |
| sip_password | string | SIP account password (stored encrypted) |
| is_active | boolean | Whether this phone config is active |
Returns the outgoing email configuration for the given employee. Passwords and OAuth tokens are always returned masked.
{
"employee_id": 5,
"provider": "smtp",
"email_address": "john.doe@example.com",
"display_name": "John Doe — Repair Angel",
"smtp_host": "mail.example.com",
"smtp_port": 587,
"smtp_encryption": "tls",
"smtp_username": "john.doe@example.com",
"smtp_password": "***masked***",
"is_default": true
}Creates or replaces the employee's outgoing email configuration. All fields are optional — only supplied fields are updated.
| Field | Type | Description |
|---|---|---|
| provider | string | smtp, outlook, or google_workspace |
| email_address | string | Sender email address |
| display_name | string | Sender display name shown to recipients |
| smtp_host | string | SMTP server hostname (SMTP provider only) |
| smtp_port | integer | SMTP port, typically 587 (TLS) or 465 (SSL) |
| smtp_encryption | string | tls or ssl |
| smtp_username | string | SMTP authentication username |
| smtp_password | string | SMTP authentication password (stored encrypted) |
| is_default | boolean | If true, this becomes the employee's default sending account |
Sends a test email to the employee's configured address using the saved outgoing email settings. Returns a success flag and message.
// Response 200 — success { "success": true, "message": "Test email delivered to john.doe@example.com" } // Response 200 — failure { "success": false, "message": "SMTP connection refused on port 587" }