OpenMES provides a versioned REST API for ERP integration, custom dashboards, and automation.
Base URL: https://your-openmmes-url/api
All API endpoints (except /api/health and /api/auth/login) require a Bearer token.
Via the web UI (recommended):
- Log in as Admin
- Go to Settings → API Tokens
- Create a new token and copy it
Via the API:
POST /api/auth/login
Content-Type: application/json
{
"username": "admin",
"password": "your-password"
}Response:
{
"token": "1|abc123...",
"user": {
"id": 1,
"username": "admin",
"email": "admin@example.com",
"role": "Admin"
}
}Include the token in every request:
Authorization: Bearer 1|abc123...POST /api/auth/logout
Authorization: Bearer 1|abc123...All responses return JSON. Successful responses follow this structure:
{
"data": { ... },
"meta": { ... }
}Lists include pagination metadata:
{
"data": [ ... ],
"meta": {
"current_page": 1,
"last_page": 5,
"per_page": 15,
"total": 72
}
}Check if the server is running. No authentication required.
GET /api/healthResponse:
{
"status": "ok",
"timestamp": "2025-04-03T10:00:00+00:00"
}GET /api/auth/me
Authorization: Bearer <token>Response:
{
"id": 1,
"username": "admin",
"email": "admin@example.com",
"name": "Administrator",
"role": "Admin"
}POST /api/auth/change-password
Authorization: Bearer <token>
Content-Type: application/json
{
"current_password": "old-password",
"new_password": "new-secure-password",
"new_password_confirmation": "new-secure-password"
}Returns all active production lines.
GET /api/v1/lines
Authorization: Bearer <token>Response:
{
"data": [
{
"id": 1,
"name": "Line A",
"code": "LA",
"description": "Main assembly line",
"is_active": true,
"division": null,
"workstations_count": 4
}
]
}GET /api/v1/lines/{id}
Authorization: Bearer <token>GET /api/v1/work-orders
Authorization: Bearer <token>Query parameters:
| Parameter | Type | Description |
|---|---|---|
status |
string | Filter by status: pending, accepted, in_progress, completed, paused, rejected |
line_id |
integer | Filter by line |
due_before |
date | Filter by due date (YYYY-MM-DD) |
week_number |
integer | Filter by production week |
month_number |
integer | Filter by production month |
production_year |
integer | Filter by production year |
search |
string | Search in order_no and product_name |
per_page |
integer | Results per page (default: 15, max: 100) |
page |
integer | Page number |
Response:
{
"data": [
{
"id": 42,
"order_no": "WO-2025-0042",
"product_name": "Wooden Chair Model A",
"quantity": 100,
"status": "in_progress",
"priority": 2,
"due_date": "2025-04-10",
"week_number": 15,
"month_number": 4,
"production_year": 2025,
"line": {
"id": 1,
"name": "Line A"
},
"product_type": {
"id": 3,
"name": "Wooden Chair"
},
"batches_count": 2,
"produced_quantity": 65,
"created_at": "2025-04-01T08:00:00Z",
"updated_at": "2025-04-02T14:30:00Z"
}
],
"meta": { ... }
}GET /api/v1/work-orders/{id}
Authorization: Bearer <token>Returns full detail including batches and their steps.
POST /api/v1/work-orders
Authorization: Bearer <token>
Content-Type: application/json
{
"order_no": "WO-2025-0099",
"product_name": "Wooden Chair Model B",
"quantity": 50,
"line_id": 1,
"product_type_id": 3,
"process_template_id": 2,
"priority": 1,
"due_date": "2025-04-20",
"week_number": 17,
"production_year": 2025
}Required fields: order_no, quantity
Response: 201 Created with the created work order object.
PATCH /api/v1/work-orders/{id}
Authorization: Bearer <token>
Content-Type: application/json
{
"priority": 3,
"due_date": "2025-04-25"
}Only updatable when status is pending or accepted.
DELETE /api/v1/work-orders/{id}
Authorization: Bearer <token>Only deletable when status is pending or rejected. Returns 204 No Content.
Stopping a running order, changing what it builds under review, and resuming on the new configuration (#182). Nothing here rewrites execution data: completed batch steps, recorded consumption, quality results and produced quantities are never modified by a change.
POST /api/v1/work-orders/{id}/stop
Authorization: Bearer <token>
Content-Type: application/json
{
"type": "ENGINEERING_CHANGE",
"reason": "Hole diameter must be changed before continuing production",
"batch_id": 18,
"requires_change": true,
"downtime_reason_id": 4
}type is one of OPERATIONAL, MATERIAL_SHORTAGE, MACHINE_FAILURE,
QUALITY_HOLD, ENGINEERING_CHANGE, OTHER. Required: type, reason.
The stop records who stopped production and when, and photographs the state at that
moment (produced quantity, batches, in-progress steps, allocated/consumed material,
active configuration version). requires_change: true sets the order to
CHANGE_HOLD — resume is then refused until an approved change request has been
applied; otherwise the order goes to PAUSED. Supplying downtime_reason_id also
opens a linked production_downtimes record, closed automatically on resume.
Only an IN_PROGRESS order can be stopped, and only one stop may be open at a time
(422 otherwise). Response: 201 Created.
GET /api/v1/work-orders/{id}/stops
Authorization: Bearer <token>Newest first. Each row carries duration_minutes once resumed, and
duration_minutes_current as a running total while the stop is still open.
POST /api/v1/work-orders/{id}/resume
Authorization: Bearer <token>
Content-Type: application/json
{
"change_request_id": 52,
"notes": "Production resumed using product revision C"
}Both fields are optional — an order paused the simple way resumes on an empty body.
An order on CHANGE_HOLD must quote a change request that has been applied
(not merely approved), or the call answers 422. Resuming closes the open stop,
stamps its duration and returns the order to IN_PROGRESS.
POST /api/v1/work-orders/{id}/change-requests
Authorization: Bearer <token>
Content-Type: application/json
{
"title": "Move remaining production to revision C",
"reason": "Customer approved ECO-118",
"proposed": {
"product_revision_id": 42,
"planned_qty": 150,
"bom_template_ids": [7, 9]
},
"effective_from": "NEXT_BATCH",
"produced_disposition": "Units 1-35 ship as revision B",
"material_disposition": "Return unused revision-B brackets to stock"
}proposed accepts only these fields: product_revision_id, planned_qty,
line_id, bom_template_ids, due_date, description, production_notes.
Anything else is rejected. effective_from is NEXT_BATCH (default),
REMAINING_QUANTITY or IMMEDIATE.
The response carries the generated code (CR/2026/0001) and the impact analysis:
produced vs. remaining quantity, batch and step counts, allocated/consumed material,
the revision change, the engineering documents being replaced, and warnings where the
proposal conflicts with completed work.
GET /api/v1/work-orders/{id}/change-requests
GET /api/v1/work-order-change-requests/{id}
GET /api/v1/work-order-change-requests/{id}/impact
PATCH /api/v1/work-order-change-requests/{id}GET .../{id} includes a field-by-field diff. GET .../impact recomputes the
analysis live for a review screen (the stored impact is frozen as the approver saw
it). PATCH works on drafts only.
POST /api/v1/work-order-change-requests/{id}/submit
POST /api/v1/work-order-change-requests/{id}/approve
POST /api/v1/work-order-change-requests/{id}/reject { "reason": "..." }
POST /api/v1/work-order-change-requests/{id}/cancel
POST /api/v1/work-order-change-requests/{id}/applyStatus flow: DRAFT → SUBMITTED → APPROVED → APPLIED, with REJECTED and
CANCELLED as alternative endings. Any other transition answers 422. Rejection
requires a reason. approve, reject and apply require the
approve work order changes permission (Admin and Supervisor by default); raising
and editing a request needs only edit work orders.
POST /api/v1/work-order-change-requests/{id}/apply
Authorization: Bearer <token>
Content-Type: application/json
{
"effective_from": "REMAINING_QUANTITY",
"implementation_notes": "Applied during the night shift"
}Applying is only allowed on an approved request against a stopped order. It
captures the before-state, rebuilds the configuration and appends it as the next
work_order_snapshots version — earlier versions stay readable exactly as the shop
floor received them, and new batches are stamped with the version they were generated
from. The remaining material requirements are recalculated onto the record; existing
allocations and consumption are untouched.
Refused with 422 when the planned quantity would fall below what was already
produced, or when IMMEDIATE is requested after any step has been started or
completed (use NEXT_BATCH or REMAINING_QUANTITY).
A batch is a production run for a work order. Large orders may have multiple batches.
GET /api/v1/work-orders/{workOrderId}/batches
Authorization: Bearer <token>Response:
{
"data": [
{
"id": 10,
"work_order_id": 42,
"quantity": 30,
"status": "completed",
"started_at": "2025-04-02T08:00:00Z",
"completed_at": "2025-04-02T11:30:00Z",
"operator": {
"id": 5,
"username": "operator1"
},
"steps": [ ... ]
}
]
}Start a new production run for a work order.
POST /api/v1/work-orders/{workOrderId}/batches
Authorization: Bearer <token>
Content-Type: application/json
{
"quantity": 25
}The work order must be in accepted or in_progress status.
GET /api/v1/batches/{id}
Authorization: Bearer <token>Each batch progresses through steps defined in the process template.
POST /api/v1/batch-steps/{batchStepId}/start
Authorization: Bearer <token>Marks the step as in progress and records the start time and operator.
POST /api/v1/batch-steps/{batchStepId}/complete
Authorization: Bearer <token>
Content-Type: application/json
{
"comment": "Completed without issues"
}comment is optional.
POST /api/v1/batch-steps/{batchStepId}/problem
Authorization: Bearer <token>
Content-Type: application/json
{
"issue_type_id": 2,
"description": "Material crack found during assembly"
}This also creates an Issue linked to the work order.
Issues (Andon system) track problems reported during production.
GET /api/v1/issues
Authorization: Bearer <token>Query parameters:
| Parameter | Type | Description |
|---|---|---|
status |
string | open, acknowledged, resolved, closed |
line_id |
integer | Filter by line |
work_order_id |
integer | Filter by work order |
issue_type_id |
integer | Filter by issue type |
Response:
{
"data": [
{
"id": 7,
"work_order_id": 42,
"issue_type": {
"id": 2,
"name": "Material shortage",
"is_critical": false
},
"description": "Steel rods out of stock",
"status": "acknowledged",
"reported_by": {
"id": 5,
"username": "operator1"
},
"acknowledged_by": {
"id": 3,
"username": "supervisor1"
},
"created_at": "2025-04-02T09:15:00Z"
}
]
}GET /api/v1/issues/{id}
Authorization: Bearer <token>POST /api/v1/issues
Authorization: Bearer <token>
Content-Type: application/json
{
"work_order_id": 42,
"issue_type_id": 2,
"description": "Detailed description of the problem"
}POST /api/v1/issues/{id}/acknowledge
Authorization: Bearer <token>Requires Supervisor or Admin role.
POST /api/v1/issues/{id}/resolve
Authorization: Bearer <token>
Content-Type: application/json
{
"resolution_notes": "Restocked from warehouse B"
}POST /api/v1/issues/{id}/close
Authorization: Bearer <token>Returns issue counts grouped by line.
GET /api/v1/issues/stats/line
Authorization: Bearer <token>GET /api/v1/issue-types
Authorization: Bearer <token>Response:
{
"data": [
{
"id": 1,
"name": "Machine breakdown",
"description": "Equipment failure requiring maintenance",
"is_critical": true
},
{
"id": 2,
"name": "Material shortage",
"is_critical": false
}
]
}Admin role required.
POST /api/v1/issue-types
PATCH /api/v1/issue-types/{id}
DELETE /api/v1/issue-types/{id}Import work orders in bulk from a CSV or Excel file.
POST /api/v1/csv-imports/upload
Authorization: Bearer <token>
Content-Type: multipart/form-data
file=@work_orders.csvResponse includes the parsed headers so you can build the column mapping.
POST /api/v1/csv-imports/execute
Authorization: Bearer <token>
Content-Type: application/json
{
"import_id": "abc123",
"strategy": "insert_or_update",
"mapping": {
"order_no": "Order Number",
"quantity": "Qty",
"product_name": "Description",
"line_id": "Line Code",
"due_date": "Due Date"
}
}strategy options: insert_only, update_only, insert_or_update
GET /api/v1/csv-imports
Authorization: Bearer <token>GET /api/v1/csv-imports/{id}
Authorization: Bearer <token>GET /api/v1/csv-import-mappings
POST /api/v1/csv-import-mappingsSupervisor and Admin roles required.
Key production metrics for the current period.
GET /api/v1/analytics/overview
Authorization: Bearer <token>Response:
{
"data": {
"total_orders": 152,
"completed_orders": 98,
"in_progress_orders": 24,
"pending_orders": 30,
"open_issues": 7,
"on_time_rate": 0.89,
"avg_cycle_time_hours": 4.2
}
}GET /api/v1/analytics/production-by-line
Authorization: Bearer <token>GET /api/v1/analytics/cycle-time
Authorization: Bearer <token>Query parameters: line_id, from (date), to (date)
GET /api/v1/analytics/throughput
Authorization: Bearer <token>Query parameters: period (daily, weekly, monthly), line_id
GET /api/v1/analytics/issue-stats
Authorization: Bearer <token>GET /api/v1/analytics/step-performance
Authorization: Bearer <token>Supervisor and Admin roles required.
GET /api/v1/reports/production-summary
Authorization: Bearer <token>Query parameters: from, to, line_id
GET /api/v1/reports/batch-completion
Authorization: Bearer <token>GET /api/v1/reports/downtime
Authorization: Bearer <token>GET /api/v1/reports/export-csv?report=production-summary&from=2025-04-01&to=2025-04-30
Authorization: Bearer <token>Returns a CSV file download.
Admin role required.
GET /api/v1/audit-logs
Authorization: Bearer <token>Query parameters: from, to, user_id, entity_type
GET /api/v1/audit-logs/entity?entity_type=WorkOrder&entity_id=42
Authorization: Bearer <token>GET /api/v1/audit-logs/export?from=2025-04-01&to=2025-04-30
Authorization: Bearer <token>Returns a CSV file download.
GET /api/v1/event-logs
Authorization: Bearer <token>GET /api/v1/event-logs/entity?entity_type=WorkOrder&entity_id=42
Authorization: Bearer <token>Everything under /api/v1/erp/* is the machine-to-machine surface an ERP talks to.
It is ERP-agnostic: one canonical JSON contract that any ERP (SAP, Comarch,
enova365, Dynamics / Business Central, Pantheon, Subiekt, …) can be mapped onto.
The mapping from a specific ERP's tables to this contract is integration work on
the ERP side — OpenMES deliberately ships no vendor-specific adapters.
Requires the ERP integration module to be enabled (Settings → Modules); with it off every endpoint below returns 404.
These endpoints do not use user tokens. They authenticate with a per-integration API key, created in Settings → API Keys, and are authorized per endpoint by scope.
POST /api/v1/erp/products/import
X-Api-Key: omk_xxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/jsonAuthorization: Bearer omk_… is accepted as an alternative to the X-Api-Key header.
| Scope | Grants |
|---|---|
erp:orders:import |
Import work orders |
erp:production:read |
Read production completions |
erp:quality:read |
Read quality / non-conformance reports |
erp:masterdata:write |
Import products, materials, material lots and recipes |
erp:stock:read |
Read warehouse balances and the stock-document backlog |
erp:stock:write |
Sync warehouse balances, acknowledge stock documents |
A key sees only its own tenant's data. Missing or inactive key → 401; key without
the required scope → 403.
Import response contract. Every */import endpoint processes rows
independently and answers with a per-row report — one unresolvable reference never
fails the batch:
{
"data": {
"imported": 12,
"updated": 3,
"skipped": 1,
"errors": [
{ "row": 7, "field": "material_code", "message": "Material 'GHOST' not found" }
]
}
}200 when errors is empty, 207 Multi-Status when some rows failed, 422 when
the payload itself is malformed (wrong types, missing required keys, too many rows).
strategy on the master-data imports: update_or_create (default), skip_existing,
error_on_duplicate.
POST /api/v1/erp/work-orders/import # scope: erp:orders:import
GET /api/v1/erp/work-orders/{id} # scope: erp:production:read
GET /api/v1/erp/production/completions # scope: erp:production:read
GET /api/v1/erp/quality/issues # scope: erp:quality:read{
"strategy": "update_or_create",
"orders": [
{
"order_no": "WO-2026-001",
"line_code": "L1",
"product_type_code": "BREAD-01",
"planned_qty": 500,
"due_date": "2026-09-01",
"customer_order_no": "PO-77",
"priority": 10
}
]
}The export endpoints are cursor-paginated — follow meta.next_cursor until it is
null — and accept ?since=<ISO timestamp> for incremental polling.
All four require the erp:masterdata:write scope.
POST /api/v1/erp/products/import{
"external_system": "pantheon",
"only_categories": ["FINISHED", "SEMI"],
"products": [
{
"code": "BREAD-01",
"name": "Rye bread 500g",
"category": "FINISHED",
"unit_of_measure": "pcs"
}
]
}ERPs keep finished products and raw materials in one item table, told apart by a
classification code (Pantheon calls it a Classification, acClassif). Send the whole
item list and let only_categories decide what becomes a product — matching is
case-insensitive, and rows outside the allowlist come back as skipped. Omit
only_categories to accept everything. Products are matched by code.
POST /api/v1/erp/materials/import{
"only_categories": ["RAW", "PACKAGING"],
"materials": [
{
"code": "FLOUR-01",
"name": "Rye flour",
"category": "RAW",
"unit_of_measure": "kg",
"tracking_type": "batch",
"unit_price": 2.35,
"price_currency": "EUR",
"min_stock_level": 500
}
]
}tracking_type: none | batch | serial. The row's material_type_code (or, if
absent, its category) becomes the OpenMES material type and is created on first use.
POST /api/v1/erp/material-lots/import{
"warehouse_code": "0100",
"lots": [
{
"material_code": "FLOUR-01",
"lot_number": "L-2026-0431",
"quantity_available": 120.5,
"unit_of_measure": "kg",
"expiry_date": "2026-12-31",
"supplier_lot_no": "SUP-99"
}
]
}The ERP is authoritative about what physically exists, so quantity_available
replaces the lot's remaining quantity — re-running the sync converges instead of
inflating stock. Lots are matched by (material_code, lot_number).
warehouse_code (top level, or per row) matches a warehouse's code or its
erp_code. When a warehouse is named, the per-warehouse lot balance is written, the
material's warehouse total is recomputed from its lots, and the global material
quantity is re-derived from those totals (booked as an audited adjustment in the
stock ledger).
POST /api/v1/erp/boms/import{
"mode": "replace",
"recipes": [
{
"product_type_code": "BREAD-01",
"components": [
{ "material_code": "FLOUR-01", "quantity_per_unit": 0.5, "scrap_percentage": 2 },
{ "material_code": "YEAST-01", "quantity_per_unit": 0.01 }
]
}
]
}quantity_per_unit is the quantity per one unit of finished product — total
consumption is that times the quantity produced, which is how ERPs store recipes.
The recipe attaches to the product's process template (process_template_version
picks one explicitly; otherwise the newest active template wins).
mode: replace (default — the payload becomes the template's complete component
list, so components the ERP dropped are removed) or merge (upsert only what is
listed). A recipe with one unknown material is reported as a single failed row and
applied not at all — never half-way.
Requires the Warehouses module in addition to the ERP module.
POST /api/v1/erp/stock/import # scope: erp:stock:write{
"warehouse_code": "0100",
"balances": [
{ "material_code": "FLOUR-01", "quantity": 340.25, "unit_of_measure": "kg" },
{ "warehouse_code": "0200", "product_type_code": "BREAD-01", "quantity": 640 }
]
}Each row names exactly one item — material_code or product_type_code. The
quantity is a snapshot that replaces the OpenMES balance, and the resulting change to
the global per-material quantity is booked as an audited adjustment, so allocation,
MRP and the shortage reports never see an unexplained jump.
GET /api/v1/erp/stock?warehouse=0100&since=2026-08-01T00:00:00Z # scope: erp:stock:read{
"data": [
{
"warehouse_code": "RAW-1",
"warehouse_erp_code": "0100",
"material_code": "FLOUR-01",
"product_type_code": null,
"lot_number": "L-2026-0431",
"quantity": 120.5,
"unit_of_measure": "kg",
"erp_synced_at": "2026-08-03T18:34:38+00:00",
"updated_at": "2026-08-03T18:34:38+00:00"
}
],
"meta": { "next_cursor": null, "has_more": false, "count": 1, "per_page": 100 }
}A row with lot_number: null is the material's total in that warehouse; rows
carrying a lot are its breakdown — do not add the two together.
The warehouse paperwork production generates: a material release for what a work order consumed and a product receipt for what it produced (plus the reverse types). A completed work order creates them as drafts; posting one is what moves stock.
GET /api/v1/erp/stock-documents?unsynced_only=1 # scope: erp:stock:read{
"data": [
{
"id": 3,
"document_no": "MI/2026/0001",
"type": "material_issue",
"status": "posted",
"direction": "out",
"warehouse_code": "RAW-1",
"warehouse_erp_code": "0100",
"work_order_no": "WO-2026-001",
"erp_reference": null,
"erp_synced_at": null,
"posted_at": "2026-08-03T18:37:13+00:00",
"lines": [
{
"material_code": "FLOUR-01",
"product_type_code": null,
"lot_number": "L-2026-0431",
"quantity": 51,
"unit_of_measure": "kg",
"notes": null
}
]
}
],
"meta": { "next_cursor": null, "has_more": false, "count": 1, "per_page": 100 }
}type: material_issue | material_receipt | product_receipt | product_issue.
direction is in or out — line quantities are always positive, the type carries
the sign. Defaults to status=posted, because a draft is not a real stock movement
yet; add &unsynced_only=1 for only what the ERP has not booked, and ?since= /
?type= / ?warehouse= to narrow further.
POST /api/v1/erp/stock-documents/{id}/ack # scope: erp:stock:write
Content-Type: application/json
{ "erp_reference": "RW-2026/00042" }Records the ERP's own document number and stamps erp_synced_at, which takes the
document off the unsynced_only backlog. erp_reference is optional. Unknown id (or
another tenant's document) → 404.
| HTTP Status | Meaning |
|---|---|
200 OK |
Request successful |
201 Created |
Resource created |
204 No Content |
Request successful, no body |
400 Bad Request |
Invalid request data |
401 Unauthorized |
Missing or invalid token |
403 Forbidden |
Insufficient permissions |
404 Not Found |
Resource not found |
422 Unprocessable Entity |
Validation failed |
429 Too Many Requests |
Rate limit exceeded |
500 Internal Server Error |
Server error |
{
"message": "The given data was invalid.",
"errors": {
"order_no": ["The order no field is required."],
"quantity": ["The quantity must be a positive number."]
}
}API endpoints are rate-limited to prevent abuse:
- Authentication endpoints (
/api/auth/login): 10 requests per minute per IP - All other endpoints: 120 requests per minute per token
When the limit is exceeded, the server returns 429 Too Many Requests with a Retry-After header indicating when to retry.