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
Keys are created where they are used. Add a REST API node to a pipeline in your project (you will configure it in Step 2), set its authentication to API Key (X-API-Key), and use Project API Keys → Create Key. It needs Access Settings.
| 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/line-status |
| Auth | API Key (X-API-Key) — a new node starts Public (No Auth), so change it |
| 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.
The caller names the line in the query string — ?line=Line1 — because a route is a path, not a
template: there are no :line placeholders. The editor shows the address the route is served at,
/gateway/<project-slug>/api/v1/line-status. The examples below use a project whose slug is
plant-a.
Python node
def transform(payload, message):
line = payload["params"].get("line")
if not line:
return {"http_status": 400, "error": "line is required"}
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 {"http_status": 503, "error": f"line data unavailable: {line}"}
return {
"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.
It takes the status code from http_status (200 when absent) and sends the payload as the body; an
error field answers {"error": "…"} instead.
Step 3 — Test it
curl -H "X-API-Key: <key>" \
"https://qubiq.example.com:8090/gateway/plant-a/api/v1/line-status?line=Line1"
{"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/gateway/plant-a/api/v1/line-status?line=Line1"
# 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) → Switch ─ 1 → SQL Query → HTTP Response
└ 2 → HTTP Response (the rejection)
| Setting | Value |
|---|---|
| Method | POST |
| URL pattern | /api/v1/orders |
| Auth | API Key (X-API-Key) |
| Required scopes | db:read |
| Max payload size | 65536 |
| Rate limit / burst | 5 / 10 |
The scope is checked at the gateway, against the route: the key must carry db:read to reach
this endpoint at all. The write itself is done by the pipeline's SQL node, under the pipeline's
connection — the caller never gets to say what SQL runs.
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 {"http_status": 400, "error": f"{field} is required"}
if not isinstance(order["qty"], int) or order["qty"] <= 0:
return {"http_status": 400, "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.
Switch — keep a rejection away from the database
Property: payload.order
| Rule | Operator | Output |
|---|---|---|
| 1 | is not null | → SQL Query |
| 2 | otherwise | → HTTP Response |
Without it, a rejected request would carry on into the SQL node with no order to insert — the
statement would fail, nothing would answer, and the caller would get a 504 instead of your 400.
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.
HTTP Response after the insert — put a small Python node in front of it returning
{"http_status": 201, "accepted": …} if you want the caller to see 201 rather than the SQL node's
result with a 200.
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) with its
/gateway/<project-slug> prefix, 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 — a new node starts public, so this is a change you make
- 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