Skip to content

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.

curl "https://example.com/api/v2/bookings" \
  -H "Authorization: Bearer {token}"

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

  1. Create the customer (once per portal account), including the price group — without price_category_id automatic pricing finds no price row:

    curl -X POST "https://example.com/api/v2/customers" \
      -H "Authorization: Bearer {token}" \
      -d '{"name": "Muster GmbH", "email": "kunde@example.com", "price_category_id": "{price-group-uid}"}'
    
  2. Reserve an order number — POST /api/v2/bookings/generate-number with {"status": "{offer-request-uid}"}. Returns generated: false when 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 Angebotsanfrage status (e.g. AF-[YYYY]-[STATUS_COUNT]) so requests carry calServer numbers.

  3. Create the order — POST /api/v2/bookings with 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.

  4. Create positions — per requested device type POST /api/v2/bookings/{id}/types with {"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's comment field via PUT /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:

  1. The customer's address goes onto the customer record itself (POST /customers or PUT /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.

  2. 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 Delivery and Billing; a contact person without a type works as well. Note that the town is called location in the contact schema, not city.

  3. 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

curl "https://example.com/api/v2/bookings/{id}/next-actions" \
  -H "Authorization: Bearer {token}"

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.