Tutorial: Publish a REST Endpoint
Publish two endpoints an external system can call: a read endpoint returning line status, and a write endpoint accepting an order — both authenticated, scoped and rate-limited.
You will need: tags with live data, and a SQL or MongoDB connection for the write endpoint. Time: ~30 minutes.
Step 1 — Create an API key
Settings → API keys → New.
| Field | Value |
|---|---|
| Name | mes-integration |
| Scopes | tag:read, db:read |
Copy the key now — it is shown once, and only a hash is stored.
Note the scopes: this key can read tags and run named queries. It cannot write tags, and it cannot run arbitrary SQL. Everything below is designed around that.
→ API keys
Step 2 — Build the read endpoint
REST API (ingress) → Python → HTTP Response
REST API node
| Setting | Value |
|---|---|
| Mode | ingress |
| Method | GET |
| URL pattern | /api/v1/lines/:line/status |
| Auth type | api_key |
| Required scopes | tag:read |
| Allowed IPs | The MES's CIDR |
| Rate limit / burst | 10 / 20 |
| CORS | off |
Five independent controls, each cheap. An integration endpoint for a known system should have all of them.
Python node
def transform(payload, message):
line = payload["params"]["line"]
tags = system.tag.readAll([
f"Site1/{line}/Filler/Motor1/Speed",
f"Site1/{line}/Filler/Motor1/Running",
])
if any(t["quality"] != "Good" for t in tags):
return {"status": 503,
"body": {"error": "line data unavailable", "line": line}}
return {"status": 200, "body": {
"line": line,
"speed": tags[0]["value"],
"running": tags[1]["value"],
"ts": tags[0]["server_timestamp"],
}}
Returning 503 on bad quality is the honest answer. Returning a stale value with a 200 tells the caller everything is fine when it is not.
HTTP Response node — every ingress path must reach one, or the caller waits for the timeout.
Step 3 — Test it
curl -H "X-API-Key: <key>" \
https://qubiq.example.com:8090/api/v1/lines/Line1/status
{"line":"Line1","speed":2450.5,"running":true,"ts":"2026-08-02T09:15:03Z"}
Then verify the controls actually work:
# no key → 401
curl https://qubiq.example.com:8090/api/v1/lines/Line1/status
# from outside the allow-list → 403
# above the rate limit → 429
Testing that the negatives fail is the part that matters.
Step 4 — Build the write endpoint
REST API (ingress POST) → Python (validate) → SQL Query → HTTP Response
| Setting | Value |
|---|---|
| Method | POST |
| URL pattern | /api/v1/orders |
| Auth type | api_key |
| Required scopes | db:read |
| Max payload size | 65536 |
| Rate limit / burst | 5 / 10 |
Note the endpoint requires only db:read, because the write goes through a named query — which
is exactly why named queries let integrations run at lower privilege.
Python node — validate before anything else
def transform(payload, message):
order = payload.get("body") or {}
for field in ("orderId", "line", "qty"):
if field not in order:
return {"status": 400,
"body": {"error": f"{field} is required"}}
if not isinstance(order["qty"], int) or order["qty"] <= 0:
return {"status": 400,
"body": {"error": "qty must be a positive integer"}}
return {"order": order}
The request arrives from outside your system. Treat every field as untrusted, and reject with a message the integrator can act on.
The insert
Type the statement straight into the SQL node. The Python above returns {"order": …}, so the
placeholders reach into that object:
INSERT INTO orders (order_id, line, qty, received_at)
VALUES ({{payload.order.orderId}}, {{payload.order.line}}, {{payload.order.qty}}, NOW())
ON CONFLICT (order_id) DO NOTHING
Each {{…}} becomes a bound parameter, so a hostile order_id cannot alter the statement — which
matters more here than anywhere else, because this payload came from outside your network.
ON CONFLICT DO NOTHING makes a retried delivery safe. External systems retry.
Step 5 — Give the caller real status codes
| Code | When |
|---|---|
200 / 201 | Success |
400 | Malformed request — say which field |
401 | Missing or invalid API key |
403 | Valid key, missing scope, or blocked source IP |
404 | Unknown resource |
409 | Duplicate — already accepted |
429 | Rate limited |
503 | Data unavailable (bad quality, connection down) |
A 200 carrying an error message is hostile to whoever integrates with you at 03:00.
Step 6 — Version and document
Version the URL (/api/v1/...). Changing a pipeline's response shape breaks callers exactly as
changing any API does — and pipelines are easy to change.
Give the integrator: the base URL and port (8090, not the UI port), the endpoints and shapes, their API key's scopes, the rate limit, and the error codes above.
Step 7 — Operate it
| Watch | Where |
|---|---|
| Failed executions | The execution log panel |
| Key usage and rejections | Audit journal |
| Rate-limit hits | Usually a misconfigured client — or an attempt |
The pipeline must be running for its routes to serve. A stopped pipeline returns 404, which is the first thing to check when an integrator reports the endpoint has vanished.
Security checklist
- Every route requires an API key — public is a deliberate choice, made once
- Scopes are the narrowest that work
- IP allow-list set for known callers
- Rate and burst limits set slightly above real traffic
- Max payload size bounded
- CORS off unless a browser genuinely calls it
- Inputs validated before any database or tag access
- Writes are idempotent
- Each consumer has its own key
- Port 8090 is exposed only to the networks that need it