Order integration for request portals (eforms)¶
External request portals (e.g. the eforms.cloud eCalserver module) file calibration requests as orders in calServer and accompany them through the offer workflow. This page describes the binding integration contract against the V2 API.
V2 only
The order API exists exclusively in the V2 API (Laravel). The V1 REST API has no orders — the target instance must run V2.
Workflow¶
Angebotsanfrage → Angebot → Angebot abgegeben → Auftrag → (part) delivery → complete
(offer request, (offer, (offer submitted, (order,
portal) lab) awaiting customer) customer confirmed)
| Step | Actor | Status (canonical) |
|---|---|---|
| Request filed | Portal | Angebotsanfrage (OFFER_REQUEST) |
| Offer in the making | Lab (calServer) | Angebot (OFFER) |
| Offer priced, awaiting customer | Lab (calServer) | Angebot abgegeben (OFFER_SUBMITTED) |
| Customer confirmed | Portal | Auftrag (ORDER) |
| Delivery / completion | Lab (calServer) | Lieferung / Abgeschlossen |
The statuses Angebotsanfrage and Angebot abgegeben ship canonically since
migration 2026_07_24_200000. Both render the offer document in the
order report (position mode: device types).
The configurator runs anonymously — calServer only sees the sender
In the eforms module eCalserver the visitor assembles the request
without an account: search the catalogue, pick devices and quantities,
enter an address. Until then the only thing hitting calServer is the
read-only catalogue proxy (GET /device-types, cached server-side) — no
customer record and no booking is created. A portal account is required
only to send the request; that is when the steps under "Filing a request"
run (create customer, reserve number, create booking). Anonymous
configurator sessions therefore leave no orphan rows in the customer
master data.
Service user and permissions¶
Create a dedicated calServer user for the portal and assign it the built-in
role api_integration ("API-Integration (Anfrage-Portal)"). The role ships
with every installation and holds exactly the operations the contract below
needs:
using_API, bookings_view, bookings_edit, customers_view, customers_edit,
types_view, price_category_view, dms_view
bookings_view/bookings_edit cover bookings, line items (articles), type
positions, change-status, generate-number, report-dataset and the
booking's document list; dms_view allows downloading the generated PDFs.
Status reads require no operation.
Least privilege
Sanctum tokens carry no abilities — the service user's role is the only permission boundary. Do not assign further operations. Tokens do not expire automatically; rotation is the portal operator's policy.
api_integration is only ever created, never overwritten: a role adjusted
in production stays untouched by updates.
Authentication as described in Authentication:
POST /api/v2/auth/token with the X-REST-API-KEY header and
{"email": …, "password": …}; the bearer token is used for all subsequent
requests. Rate limit: 60 requests/minute per user.
Choosing the access path: API token or login¶
There are two ways for the portal to authenticate. The API token is recommended, because then neither the service user's password nor its API key sits with the portal operator — the password is also a UI login.
Issue a token under Administration → Users → [User] → API token and
store the value in the portal. It is used directly as
Authorization: Bearer …, with no /auth/token call.
The plain value appears exactly once, on issue — only a SHA-256 hash is stored. That is uncritical: a new token does not invalidate existing ones. If the value is lost, issue a new one and revoke the old one without disturbing other integrations. An expiry date can be set optionally.
A token carries the full permissions of its user; Sanctum abilities are not evaluated. The role therefore remains the only permission boundary.
The classic path via POST /auth/token with the X-REST-API-KEY header and
{"email": …, "password": …}.
Every newly created user automatically receives an API key. Manage it under
Administration → Users → [User] → API key; the stored key can be
viewed, copied, regenerated and removed there at any time. A user without a
key cannot authenticate via /auth/token, the endpoint answers with
401 Invalid API key. For users created before this version, click
"Generate API key" once.
When the user is deleted
Deleting the service user immediately invalidates its tokens and its API
key. The portal then reports 401.
Status resolution (never hard-code titles)¶
Status titles are configurable per installation. bookings.status and
POST /bookings/{id}/change-status work with the status uID. The portal
loads the list once (or for its configuration UI) via:
curl "https://example.com/api/v2/statuses?filter[type]=booking&filter[active]=1" \
-H "Authorization: Bearer {token}"
and stores the mapped uIDs (offer request, offer submitted, order, delivery, complete) in its own configuration.
Searching the device catalogue¶
The portal's configurator looks up requestable devices via
GET /api/v2/device-types. Three filters are available, all of them
case-insensitive substring matches (working identically on MySQL,
PostgreSQL and MS SQL Server):
| Filter | Searches in |
|---|---|
filter[search] |
type name, manufacturer, model, type code |
filter[manufacturer] |
manufacturer |
filter[model] |
model |
filter[manufacturer] and filter[model] can be combined (AND).
curl "https://example.com/api/v2/device-types?filter[manufacturer]=fluke&filter[model]=87" \
-H "Authorization: Bearer {token}"
So fluke also finds Fluke, and 87 finds 87V. For the portal this is
mandatory — end customers rarely type manufacturer names exactly the way the
catalogue spells them.
Bare value, otherwise the general filter syntax
The substring match applies to a value without an operator. A value
carrying an operator token (contains:, startswith:, ne:, …) uses the
API's general filter syntax instead — the same one every grid uses, and
available on every catalogue column, not just these three:
curl "https://example.com/api/v2/device-types?filter[description]=startswith:Digitalmultimeter&sort=-type_version" \
-H "Authorization: Bearer {token}"
It has no effect on the three computed columns (device count and the two blueprint defaults): they have no column behind them, so a filter on them is ignored.
Filing a request¶
-
Create the customer (once per portal account), including the price group — without
price_category_idautomatic pricing finds no price row: -
Reserve an order number —
POST /api/v2/bookings/generate-numberwith{"status": "{offer-request-uid}"}. Returnsgenerated: falsewhen no number range is configured on the status; the portal then falls back to a number of its own.Number range recommended
Configure a dedicated number range on the
Angebotsanfragestatus (e.g.AF-[YYYY]-[STATUS_COUNT]) so requests carry calServer numbers. -
Create the order —
POST /api/v2/bookingswith the offer-request status and the correlation marker as the first comment line:{ "customer_id": "{customer-uid}", "number": "AF-2026-0042", "status": "{offer-request-uid}", "comment": "eforms:9f4c9850-0913-4d60-a964-8643d82af652\nRequest filed via the customer portal …" }The
eforms:{uuid}marker makes retries idempotent:GET /api/v2/bookings?filter[search]=eforms:{uuid}finds the booking via the comment search before creating it again. -
Create positions — per requested device type
POST /api/v2/bookings/{id}/typeswith{"type_id": …, "quantity": …}. calServer prices the position automatically from the price matrix (device type × the customer's price group). Then write the customer's serial number/remark into the position'scommentfield viaPUT /api/v2/articles/{articleId}.
Addresses: customer record and contact roles¶
The address printed on the offer does not come from fields on the booking.
It is resolved per role: the role's contact → the role customer → the ordering
customer. What gets printed is the customer's name, customer_number,
street, zip, city, country, or the contact's own fields.
A portal that only sets name and email on the customer therefore produces
an order without a printable address — the lab has to add it by hand before
quoting. If you do collect addresses, transmit them like this:
-
The customer's address goes onto the customer record itself (
POST /customersorPUT /customers/{id}).Column width is 48 characters
The customer columns (
name,street,zip,city,country,phone,contact_person) hold 48 characters; the endpoint's validation allows more. Longer values run into a database error — the portal has to cap them itself. -
A differing delivery/billing address becomes a contact on the customer:
curl -X POST "https://example.com/api/v2/contacts" \ -H "Authorization: Bearer {token}" \ -d '{"name": "Muster GmbH Plant 2", "street": "Industriestr. 7", "zip": "54321", "location": "Lieferstadt", "type": "Delivery", "customer_id": "{customer-uid}"}'Valid types are
DeliveryandBilling; a contact person without a type works as well. Note that the town is calledlocationin the contact schema, notcity. -
Set the contacts as roles on the booking — only then does the address resolution find them:
curl -X PUT "https://example.com/api/v2/bookings/{id}" \ -H "Authorization: Bearer {token}" \ -d '{"customer_contact_id": "…", "delivery_contact_id": "…", "invoice_contact_id": "…"}'The call is idempotent and safe to repeat on a retry.
Link existing customers instead of duplicating them
Query GET /api/v2/customers?filter[email]=eq:{mail} before creating one
and reuse the match. The filter works with LIKE, so re-check the returned
row against the exact address. On an existing customer only fill in the
empty fields: customer master data belongs to the lab.
Status changes and customer confirmation¶
The lab works entirely in calServer (evaluate, add positions, set the status
to Angebot abgegeben). The portal polls and reacts:
curl -X POST "https://example.com/api/v2/bookings/{id}/change-status" \
-H "Authorization: Bearer {token}" \
-d '{"status": "{order-uid}"}'
If the transition responds with mandatory-field requirements
(additional-fields), the portal does not fill them in itself —
mandatory fields belong to the lab; the portal tells the customer that the
lab needs to complete the record.
Refused status changes¶
Since the workflow rules were introduced (ADR-027), change-status can also
refuse a transition on business grounds. The response is 422 with a payload
that is distinguishable from required_fields_missing:
{
"data": {
"success": false,
"workflow_blocked": true,
"reasons": [
{ "code": "order_stage_required", "message": "…" }
]
}
}
| Code | Meaning |
|---|---|
order_stage_required |
The target belongs to fulfilment while the record is still in the quotation phase |
no_positions |
The record has no positions |
customer_required |
No customer is assigned |
no_delivery_part |
No position is assigned to a partial delivery |
The code values are stable and meant for display; message is the German
default text for callers without their own translations.
In practice only the move to Auftrag after the customer's confirmation matters
to the portal, and that one deliberately has no precondition. A portal that
sets further statuses should evaluate the codes instead of treating the error as
a technical outage.
Transitions involving a status outside the canonical chain (tenant-specific workstations) are never refused.
Preview: what is possible next¶
Returns where the record stands, the delivery and billing progress, and every
follow-up status with allowed plus the reasons when it is not. The portal can
disable the confirm button before the customer runs into a refusal, and show an
honest progress figure:
{
"data": {
"current": { "status": "{uid}", "title": "Auftrag", "stage": "order" },
"progress": { "positions": 4, "delivered": 2, "billed": 0, "open_delivery": 2, "open_billing": 4 },
"actions": [
{ "status": "{uid}", "title": "Lieferung", "stage": "delivery", "allowed": true, "reasons": [] }
],
"recommended": "{uid}"
}
}
stage is null for a status that is not part of the canonical chain. The
endpoint requires bookings_view and is therefore covered by the
api_integration role.
Documents (offer, order confirmation, delivery note)¶
PDFs are filed in the DMS and streamed from there by the portal:
| Action | Endpoint |
|---|---|
| Generate a report and file it in the DMS | POST /api/v2/bookings/{id}/reports/save-to-dms |
| List a booking's filed documents | GET /api/v2/bookings/{id}/documents |
| Download a document | GET /api/v2/documents/{documentId}/download |
The lab files documents via "Report → Save to DMS" (or automatically via
save_to_dms on the report setting) — only then do they become visible to
the portal. The document type follows the booking status:
Angebotsanfrage/Angebot abgegeben → offer, Auftrag → order
confirmation, Lieferung → delivery note.
Polling¶
calServer sends no webhooks. The portal polls GET /api/v2/bookings/{id}
(status) and GET /api/v2/bookings/{id}/documents (new PDFs) — recommended
every 15 minutes, batched and below the rate limit (60/min). The status
history is available via GET /api/v2/bookings/{id}/audits.