Skip to content

The flow editor

One canvas is the single source of truth for routing: source nodes match records, processing nodes transform them, sink nodes deliver them. Edit, test and Deploy atomically.

Updated View as Markdown

The dashboard is one screen: a Node-RED-style canvas. The canvas is the flow document (<data_dir>/flow.json); there are no separate “transform routes” or “pipelines” to keep in sync. Records are passed as native CanonicalMachineData structs, never re-serialized between nodes.

Nodes

Node What it does
Source Claims records by a match key (see below). The pipeline for a source is everything reachable from it.
Transform A JavaScript or DSL script, with an on_error policy.
Filter Keeps records where a DSL condition holds. Works on any JSON record: fields read as metadata.<path> (see Delivering events).
Branch Routes by ordered conditions to named ports, plus an implicit else; same record view as Filter.
Map Emits one record per item of an array field.
Dedupe Idempotency: the first record with a key passes, later retries of it are dropped (see Dedupe).
Ledger Tallies {ledger, side, key, day, amount} of passing records for the daily reconciliation (see Reconciliation).
Reconcile Compares two sides of a day by key and amount and emits an exception report (see Reconciliation).
Approve Parks the record until a person with the configured role approves or rejects it; approved continues, rejected / timed out take the error port (see Finance controls).
Join Waits for every branch forked from one record and merges the results (see Join).
Sub-flow call / input / output Runs another flow tab as a function (see Sub-flows).
Link out / Link in A wire between flow tabs, fire-and-forget (see Link nodes).
Event Wraps a record in the business-event envelope {event_id, type, source, occurred_at, subject, correlation_id, data} (see Business events).
Validate Checks a record against an event type’s JSON Schema from the catalogue; an invalid record is quarantined with the reasons (see validate).
Lookup Enriches a record from a code list or cross-reference table, with on_missing fail, pass or default (see lookup).
HTTP Calls an API mid-flow with bearer, basic, OAuth 2 or NetSuite TBA authentication, a read-only posting mode, and optionally a vendored OpenAPI operation.
Sink One delivery target: push, storage, hub, nats, mqtt, clickhouse or postgres.
Comment A note on the canvas. Never compiled.

A node can be shared by several sources and a source can fan out to several nodes. A node marked disabled is treated as absent when the flow is compiled.

HTTP node: authentication and posting mode

The HTTP node calls an ERP or any REST API once per record. url, headers and body are templates: ${machine.id} reads the record (percent-encoded in the URL, JSON-escaped in the body), ${UPPER_CASE} reads an environment variable that is listed in transform.executor.http_env_allowlist. The target host must be in transform.executor.http_host_allowlist. A non-2xx answer fails the node; transport errors, 5xx, 408 and 429 are retried.

auth

auth.type Fields Sends
bearer token Authorization: Bearer …
basic username, password Authorization: Basic …
oauth2_client_credentials token_url, client_id, client_secret, optional scope, optional client_auth (basic default, or body) Authorization: Bearer <access token>
oauth1_tba realm, consumer_key, consumer_secret, token_id, token_secret NetSuite Token-Based Authentication: Authorization: OAuth realm=…, oauth_signature_method="HMAC-SHA256", …
oauth2_jwt_bearer token_url, client_id, key_id, private_key, optional algorithm (PS256 default, PS384, PS512, ES256, ES384), scope (default rest_webservices), audience (default token_url), lifetime_secs NetSuite OAuth 2.0 M2M: Authorization: Bearer <access token> obtained with a signed JWT client_assertion
login_bearer login_url, token_path, optional method, headers, body, expires_in_path, ttl_secs, refresh_url, refresh_method, refresh_token_path GlobalTix, ESB: a login request returns the token; Authorization: Bearer <token>
doku client_id, secret_key DOKU Non-SNAP: Client-Id, Request-Id, Request-Timestamp, Signature: HMACSHA256=…

Secrets are never written into the flow. token, password, client_secret, consumer_key, consumer_secret, token_id and token_secret must each be exactly one ${ENV_NAME} reference (a literal is rejected when you Deploy), and the variable must be in http_env_allowlist. The flow document, GET /api/flow, exports, the editor and MCP drafts therefore only ever contain the reference; the value is read from the environment when a request is made, travels in a header marked sensitive, and is scrubbed from error messages. The other fields (username, client_id, scope, token_url, realm) may be literals or embed ${ENV_NAME}. Record fields such as ${machine.id} are refused everywhere in auth. Setting auth together with your own Authorization header is a deploy error.

  • OAuth 2.0 client credentials. The access token is cached (shared by every http node of the running flow, keyed by token URL, client id and scope) until shortly before expires_in runs out, so a burst of records makes one token request. If the API answers 401, the cached token is dropped and the request is repeated once with a new token. A 401 that survives that fails the node with 401 Unauthorized (oauth2_client_credentials) … freshly issued OAuth2 token (a failure, not a retry: the credentials are wrong). An unreachable token endpoint or a 5xx from it is a retry; any other token endpoint answer (for example invalid_client) is a failure. The token endpoint host must be in http_host_allowlist too.

  • NetSuite OAuth 2.0 M2M (oauth2_jwt_bearer). The token request is a client-credentials grant whose client credential is a signed JWT: client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer and client_assertion=<JWT>. Header {alg, typ: "JWT", kid} with kid = key_id (the Certificate ID NetSuite shows after you upload the public certificate in Setup > Integration > OAuth 2.0 Client Credentials (M2M) Setup); claims iss = client_id, scope (array, from scope, space or comma separated), aud = the token endpoint (or audience), iat, exp (lifetime_secs, default 300, at most 3599; Oracle requires under 60 minutes). Signing uses ring: RSA-PSS (PS256/PS384/PS512) or ECDSA (ES256 P-256, ES384 P-384). private_key is a ${secret:name} or ${ENV} reference holding the PEM: PKCS#8 (BEGIN PRIVATE KEY), or PKCS#1 (BEGIN RSA PRIVATE KEY) for RSA; an EC key from openssl ecparam -genkey is SEC1, convert it with openssl pkcs8 -topk8 -nocrypt. Encrypted keys are not supported. The access token is cached and refreshed on expiry and once after a 401, exactly like oauth2_client_credentials. The key, the assertion and the token never appear in errors, traces or the dry run; Test with last record parses the key and fails with a message that does not echo it.

  • login_bearer. For APIs with a login call instead of an OAuth token endpoint. method defaults to POST; headers and body (a JSON object, string values are templates, so values are JSON-escaped) may embed ${ENV}, ${secret:name} and ${var:name}. A header or body field whose name contains key, secret, pass, token, auth or signature must contain such a reference: a literal is a deploy error. The token is read from the JSON response at token_path (data.accessToken; array indexes are numbers). Lifetime: expires_in_path (seconds) when the response has it, else ttl_secs, else one hour. A 401 drops the token and repeats the request once with a new one. With refresh_url + refresh_token_path the new token is first requested with the refresh token (refresh_method, default GET, Authorization: Bearer <refresh token>); a refused refresh falls back to the login request.

    "auth": { "type": "login_bearer",
      "login_url": "https://api.globaltix.com/api/auth/authorize",
      "headers": { "x-api-key": "${GLOBALTIX_AGENT}/${secret:globaltix_api_key}",
                   "x-api-agent": "${GLOBALTIX_AGENT}" },
      "body": { "username": "${GLOBALTIX_USER}" },
      "token_path": "data.accessToken", "ttl_secs": 86400 }
    "auth": { "type": "login_bearer",
      "login_url": "https://services.esb.co.id/core/auth/login",
      "body": { "username": "${ESB_USER}", "password": "${secret:esb_password}" },
      "token_path": "result.accessToken",
      "refresh_token_path": "result.refreshToken",
      "refresh_url": "https://services.esb.co.id/core/auth/refresh" }
  • DOKU Non-SNAP (doku). Every request carries Client-Id, Request-Id (a new UUID), Request-Timestamp (UTC, 2026-11-03T02:47:11Z) and Signature: HMACSHA256= + base64(HMAC-SHA256(secret key, component)). The component is the same text the DOKU notification check uses, with Request-Target = the request path (no host, no query) and Digest = base64(SHA-256(exact body bytes)); a request without a body (a GET) has no Digest line. The signature is computed per request, so it is never cached and a 401 is not retried.

  • NetSuite TBA. Each request is signed per RFC 5849 §3.4 with HMAC-SHA256: the signature covers the method, the URL including its query parameters, and the oauth_* parameters (the JSON body is not part of the signature base). realm is the account id, for a sandbox 1234567_SB1 (underscore), while the host uses a dash: 1234567-sb1.suitetalk.api.netsuite.com. A 401 fails the node and names the things to check (consumer and token credentials, realm, integration record and role).

  • Test with last record never sends the request. It checks that every referenced variable is allowlisted and set, and shows the auth type (never a value) in the recorded request.

mode: posting switch

mode GET POST / PUT / PATCH / DELETE
live (default) sent sent
read_only sent not sent: recorded as a no-op
sandbox_only sent sent only to a host in sandbox_hosts (exact or parent domain), otherwise not sent

A skipped request does not fail the node. The record continues out of the node with http_result: { skipped: "read_only" | "sandbox_only", mode, method, host, path } added (a branch node can route on it), the trace and the live debug stream say skipped, and a dry run shows it in the recorded effect. The URL host must still be allowlisted.

Set mode to ${NETSUITE_MODE} and flip the environment (read_only for the PoC, live at cut-over) instead of editing the flow. The variable is read per request, so it has to be in http_env_allowlist; an unset, empty or unknown value fails the node instead of defaulting to live. sandbox_only makes a wrong URL harmless: keep ${NETSUITE_ACCOUNT_HOST} in the URL and the sandbox host in sandbox_hosts, and a production host is never posted to.

mode: "${var:POST_MODE}" works the same way with an instance variable. In a protected environment that variable cannot be switched to live directly: an editor files a go-live request and a second, designated approver signs it off; the decision is audited and shown on the node (see Finance controls).

Example: NetSuite journal entry (SuiteTalk REST)

Operator side (config.yaml and the service EnvironmentFile):

transform:
  executor:
    http_host_allowlist: ["1234567-sb1.suitetalk.api.netsuite.com"]
    http_env_allowlist:
      - NETSUITE_MODE
      - NETSUITE_CONSUMER_KEY
      - NETSUITE_CONSUMER_SECRET
      - NETSUITE_TOKEN_ID
      - NETSUITE_TOKEN_SECRET
NETSUITE_MODE=read_only
NETSUITE_CONSUMER_KEY=…
NETSUITE_CONSUMER_SECRET=…
NETSUITE_TOKEN_ID=…
NETSUITE_TOKEN_SECRET=…

The node in flow.json:

{
  "id": "post-je",
  "type": "http",
  "method": "POST",
  "url": "https://1234567-sb1.suitetalk.api.netsuite.com/services/rest/record/v1/journalEntry",
  "mode": "${NETSUITE_MODE}",
  "sandbox_hosts": ["1234567-sb1.suitetalk.api.netsuite.com"],
  "merge": "merge",
  "auth": {
    "type": "oauth1_tba",
    "realm": "1234567_SB1",
    "consumer_key": "${NETSUITE_CONSUMER_KEY}",
    "consumer_secret": "${NETSUITE_CONSUMER_SECRET}",
    "token_id": "${NETSUITE_TOKEN_ID}",
    "token_secret": "${NETSUITE_TOKEN_SECRET}"
  },
  "body": "{\"subsidiary\":{\"id\":\"1\"},\"tranDate\":\"${sales.date}\",\"memo\":\"Daily sales ${sales.date}\",\"line\":{\"items\":[{\"account\":{\"id\":\"54\"},\"debit\":${sales.total}},{\"account\":{\"id\":\"211\"},\"credit\":${sales.total}}]}}"
}

NetSuite answers a successful create with 204 No Content; "merge": "merge" keeps the record and stores the (empty) response under metadata.http_response. With NETSUITE_MODE=read_only the node leaves the record untouched apart from http_result.skipped; set NETSUITE_MODE=live and restart to post. For OAuth 2.0 instead, swap the auth block:

"auth": {
  "type": "oauth2_client_credentials",
  "token_url": "https://idp.example.com/oauth2/token",
  "client_id": "opex-flow",
  "client_secret": "${ERP_CLIENT_SECRET}",
  "scope": "rest_webservices"
}

Signed webhooks

A source node whose match is webhook:{name} plus a webhook object receives calls from an external system (DOKU, a payment or shop platform) on POST /api/hooks/{name}. That one route needs no API bearer token: the call is accepted only with a valid HMAC signature (see Security). {name} is 1-64 characters of letters, digits, -, _, ., not starting with .. A hook without a signature fails the deploy.

{ "id": "doku-in", "type": "source", "match": "webhook:doku",
  "webhook": {
    "signature": { "scheme": "doku", "client_id": "MCH-0001-10791114622547",
                   "secret": "${DOKU_SECRET_KEY}", "request_target": "/payments/notify",
                   "tolerance_secs": 300 },
    "respond": "on_done", "wait_ms": 20000, "max_body_bytes": 1048576,
    "capture_headers": ["x-custom"] } }

In the editor: palette Signed webhook, then the Webhook section of the node. The form shows the URL the sender must call.

Secrets

secret must be exactly one ${ENV_VAR} (an environment variable of the connector process: systemd EnvironmentFile, Docker -e) or one ${secret:name} reference (local encrypted store or Vault, see Data protection), resolved at deploy. A missing or empty value is a deploy error. The secret is never stored in flow.json.

Signature schemes

signature.scheme Fields
doku client_id (required, must equal the Client-Id header), secret, request_target (optional path starting with /, default the request path /api/hooks/{name}), tolerance_secs
hmac_sha256 header (required: header carrying the signature), secret, encoding (hex default or base64), prefix (optional literal such as sha256=, required in the header when set), timestamp_header (optional: the signed text becomes {timestamp}.{body}; unix seconds or RFC 3339), tolerance_secs (optional, at least 1, needs timestamp_header)
globaltix secret, optional signature_field (default signature), event_field (eventType), timestamp_field (timestamp), tolerance_secs, timestamp_utc_offset_minutes (default 0)

tolerance_secs rejects timestamps further than that many seconds from now. Set it (300 is a good value) so a captured request cannot be replayed later.

Any hook may also set allow_ips, a list of IP addresses or CIDR ranges ("203.0.113.7", "198.51.100.0/24", IPv6 too): a call from another address is answered 403 before its body is read and counted in ip_denied. The address is the TCP peer. Behind a reverse proxy set client_ip_header (for example x-forwarded-for): the last comma-separated entry is used, so name only a header your proxy sets itself. With allow_ips set, a call whose address is unknown is refused.

GlobalTix notifications

GlobalTix puts the signature in the JSON body ({"requestId", "signature", "timestamp", "eventType", "data"}), not in a header: signature = hex(HMAC-SHA256(notificationSecret, "<eventType>|<timestamp>")). The globaltix scheme checks exactly that, with the three field paths configurable. timestamp has no zone (2022-10-31T12:33:30); set timestamp_utc_offset_minutes (for example 480 for UTC+8) if it is local time, otherwise tolerance_secs compares it as UTC.

{ "id": "gt-in", "type": "source", "match": "webhook:globaltix",
  "webhook": {
    "signature": { "scheme": "globaltix", "secret": "${secret:globaltix_notification_secret}",
                   "tolerance_secs": 600, "timestamp_utc_offset_minutes": 480 },
    "allow_ips": ["203.0.113.0/24"] } }

Limits you must plan for. The signature covers only the event type and the timestamp, not data. It proves the caller knows the secret; it does not protect the payload from tampering, and a captured call replays until tolerance_secs passes. Therefore: set allow_ips to GlobalTix’s source addresses (ask GlobalTix; they are not documented), put a dedupe node on ${requestId} right after the source, serve the hook over HTTPS only, and treat the notification as a trigger to re-read the booking from the GlobalTix API when the amount or status matters.

DOKU Non-SNAP notification

DOKU sends the headers Client-Id, Request-Id (unique per notification, up to 128 characters), Request-Timestamp (UTC ISO 8601) and Signature. The signature is HMACSHA256= + base64(HMAC-SHA256(secret key, component)), where the component is five lines joined by \n with no trailing newline:

Client-Id:<Client-Id header>
Request-Id:<Request-Id header>
Request-Timestamp:<Request-Timestamp header>
Request-Target:<path of the Notification URL>
Digest:<base64(sha256(raw body))>

Request-Target is the path DOKU signs: the Notification URL registered in the DOKU back office. It defaults to the called path /api/hooks/{name}; set request_target when a reverse proxy rewrites the path. See DOKU’s signature component for Non-SNAP notifications.

Test it with curl and openssl (CID is the configured client_id, SECRET the value of DOKU_SECRET_KEY, the Request-Target must equal the called path):

HOST=http://127.0.0.1:3000
BODY='{"order":{"invoice_number":"INV-1001"},"transaction":{"status":"SUCCESS"}}'
RID=$(uuidgen)
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
TARGET=/api/hooks/doku
DIGEST=$(printf %s "$BODY" | openssl dgst -sha256 -binary | base64)
SIG=$(printf 'Client-Id:%s\nRequest-Id:%s\nRequest-Timestamp:%s\nRequest-Target:%s\nDigest:%s' \
  "$CID" "$RID" "$TS" "$TARGET" "$DIGEST" \
  | openssl dgst -sha256 -hmac "$SECRET" -binary | base64)
curl -i -X POST "$HOST$TARGET" -H "Content-Type: application/json" \
  -H "Client-Id: $CID" -H "Request-Id: $RID" -H "Request-Timestamp: $TS" \
  -H "Signature: HMACSHA256=$SIG" -d "$BODY"

Generic HMAC-SHA256 (GitHub style)

{ "id": "gh-in", "type": "source", "match": "webhook:github",
  "webhook": { "signature": { "scheme": "hmac_sha256", "header": "x-hub-signature-256",
                              "prefix": "sha256=", "secret": "${GITHUB_WEBHOOK_SECRET}" } } }

The HMAC is computed over the raw body bytes:

BODY='{"action":"opened"}'
SIG=$(printf %s "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -i -X POST "$HOST/api/hooks/github" -H "X-Hub-Signature-256: sha256=$SIG" -d "$BODY"

The record

The record routed to the flow is the JSON body object plus an added key _hook:

"_hook": { "name": "doku", "received_at": "2026-10-30T08:00:00Z",
           "headers": { "client-id": "...", "request-id": "...", "request-timestamp": "..." } }

Header names are lower-case. The doku scheme always captures client-id, request-id and request-timestamp; capture_headers (at most 16 names) adds more. The Signature header is never forwarded. A body key named _hook is replaced. No Content-Type is required.

A webhook record is business JSON, not a machine record: put a transform, map or HTTP node after it (as after an ingest source); a sink needs canonical records.

Answers

HTTP Meaning
200 Done (on_done), or {"result":"accepted"} (on_receipt)
202 on_done only: the flow is still running after wait_ms
400 Signed, but the body is not a JSON object
401 {"error":"invalid signature"}: missing, wrong or stale signature, or wrong Client-Id. Counted as bad_signature, never routed; the reason is only logged and kept as last_reject
404 Unknown or disabled hook (nothing is read)
408 Body not received within 15 s
413 Body larger than max_body_bytes
422 on_done: the flow failed, resending fails again
503 on_done: transient failure, the sender should retry

Delivery semantics

  • respond: "on_done" (default): the HTTP answer waits for the flow, like /api/ingest (200 done, 422 failed, 503 retry, 202 pending after wait_ms, 1 to 120000 ms, default 20000). A failed delivery is therefore visible to the sender, whose own retry covers it. Combined with a dedupe node the effect happens effectively once.
  • respond: "on_receipt": answers 200 {"result":"accepted"} as soon as the record is queued. The sender never learns of later failures; watch dead letters and the node stats. Use it only when the sender cannot wait.
  • max_body_bytes: 1 to 16777216, default 1048576.

Counters

GET /api/status lists every hook under engine.webhooks[]: {name, node_id, path, scheme, respond, received, accepted, bad_signature, ip_denied, too_large, bad_body, unrouted, last_accepted, last_reject}.

Dedupe (idempotency)

The dedupe node (processing, one input in, one output out) makes a flow safe against a sender that delivers the same event more than once. The first record with a key passes; later records with that key inside the TTL are dropped at the node and acknowledged, so a retry causes no second effect.

{ "id": "once", "type": "dedupe",
  "key": "${_hook.headers.request-id}", "ttl": "7d", "scope": "doku", "on_missing": "fail" }
Field Meaning
key Required template. ${path.to.field} placeholders are resolved against the record, e.g. ${_hook.headers.request-id} or ${order.invoice_number}-${line}. Dotted path; array indices work (items.0.sku). Each placeholder must resolve to a non-empty string, number or bool; at least one is required
ttl Optional. Seconds (3600) or text 30s, 90m, 36h, 7d. Default 7 days, range 1 s to 366 d
scope Optional key namespace, default the node id, at most 128 characters. Two nodes with the same scope share keys
on_missing fail (default): the record is terminated with an error. pass: let it through, not deduplicated

Typical DOKU flow: source webhook:doku -> dedupe ${_hook.headers.request-id} -> transform or map -> http (NetSuite) -> sink.

Behaviour:

  • Keys are stored in SQLite <data_dir>/dedupe.db and survive restarts.
  • A key is committed only after the whole downstream of the record succeeded (every sink accepted it). A record that fails or is retried downstream leaves its key unseen, so the sender’s retry passes.
  • Two concurrent records with the same key never both pass: the second waits for the first’s outcome (duplicate if it succeeded, takes over if it failed). It waits at most until the invocation deadline, then the answer is retry.
  • Node stats carry a dup counter (records dropped as duplicates; absent while 0) next to in/out/err; the debug panel shows “duplicate: key ‘…’ was already processed”.
  • Test (dry run) never claims, commits or drops: it reports already_seen as a side effect and the record continues.
  • Deploy errors: a key without a placeholder or with an unterminated ${, a bad ttl, a scope over 128 characters.

Reconciliation: ledger and reconcile

Finance reconciles revenue flows (GlobalTix, DOKU against NetSuite) by tallying both sides. Two nodes do it inside the hub: ledger records what passes, reconcile compares two sides of a day and reports the exceptions.

Ledger node

Processing node, one input in, one output out. Writes {ledger, side, key, day, amount} of every record that reaches it into SQLite (<data_dir>/reconcile.db) and lets the record continue unchanged.

{ "id": "tally-doku", "type": "ledger", "ledger": "payments", "side": "doku",
  "key": "${payment.order_id}", "amount": "payment.amount", "day": "${payment.paid_at}",
  "timezone": "Asia/Jakarta", "retention_days": 90, "on_missing": "fail" }
Field Meaning
ledger, side Required names: letters, digits, _ - ., at most 64 characters. A ledger holds the sides that are compared.
key Required template for the record’s identity (at least one ${path}). Recording the same key again (a redelivery, a dead-letter replay) updates the row instead of adding one.
amount Optional dot path of the amount: a number or a plain decimal string ("125000.50"; no thousands separators, no exponent). Kept to 6 decimals. Without it only the presence of the key is compared.
day Optional template giving YYYY-MM-DD or an RFC 3339 timestamp (converted to timezone; a timestamp without offset is taken as written). Default: today in timezone (default UTC).
reference Optional template stored with the row (shown by GET /api/reconcile/ledger/entries).
retention_days Rows older than this many days are dropped (default 90, 1 to 3650).
on_missing fail (default): a record whose key, amount or day cannot be read is terminated and lands in the dead-letter queue. pass: let it through untallied.

Place it where the record becomes real on its side: right after the webhook’s dedupe for the hub side, after the successful http post for the ERP side (a record that fails before the node is not tallied). Test (dry run) records nothing and reports what it would write as a side effect. Deploy errors: bad names, a key or day without a placeholder, an unknown time zone, retention_days out of range.

Reconcile node

Processing node, one input in, one output out. Compares a left (the reference: what the hub saw) and a right side for a business day, stores the report and emits it as the record.

{ "id": "recon", "type": "reconcile", "name": "doku-vs-netsuite",
  "left":  { "ledger": "payments", "side": "doku" },
  "right": { "ledger": "payments", "side": "netsuite" },
  "tolerance": 0.01, "emit": "always", "alert": true }

A side is either a ledger side {ledger, side} (written by ledger nodes) or rows in the record {from, key, amount}: from is the dot path of an array of objects, key / amount are dot paths inside each element. The second form takes rows an http node pulled from the other system (its response merged into the record), so the other side need not be tallied by the hub at all:

{ "id": "recon", "type": "reconcile", "name": "doku-vs-netsuite",
  "left":  { "ledger": "payments", "side": "doku" },
  "right": { "from": "netsuite.rows", "key": "tranId", "amount": "amount", "label": "netsuite" } }
Field Meaning
name Required report name (letters, digits, _ - .).
left, right Required sides, as above; label names a side in the report (default: the ledger side’s name, else left / right). The two sides must differ.
day Optional template (YYYY-MM-DD or timestamp). Default: _schedule.previous_date of a schedule tick, else yesterday in timezone.
timezone IANA zone deciding “yesterday” and timestamp conversion (default UTC).
tolerance Largest accepted difference between the two amounts (default 0).
ignore_case Compare keys case-insensitively (keys are always trimmed).
max_exceptions Exceptions listed in the report (default 1000, at most 50 000); the counts stay exact.
emit always (default) or exceptions: with exceptions a clean day ends at the node and emits nothing.
alert true: also send the headline of a report with exceptions to the configured alert channels.

Exceptions (left is the reference):

Kind Meaning
missing_right The key is on the left only: the hub saw it, the other system never booked it.
missing_left The key is on the right only: booked, but the hub never saw it.
amount_mismatch Both have the key, the amounts differ by more than tolerance.

Several rows with one key on a side are summed (split postings). A key whose amount is unknown on a side is only checked for presence.

The emitted record is the report, so a downstream node can mail or post it:

{ "report_id": 12, "name": "doku-vs-netsuite", "day": "2026-10-30", "ok": false,
  "subject": "Reconciliation doku-vs-netsuite 2026-10-30: 3 exception(s) (1 missing in netsuite, 1 missing in doku, 1 amount mismatch)",
  "text": "…plain-text body with the first 50 exceptions…", "csv": "kind,key,doku_amount,netsuite_amount,difference\r\n…",
  "summary": { "left": {"label":"doku","count":3,"total":600}, "right": {"label":"netsuite","count":3,"total":420},
               "matched": 1, "missing_left": 1, "missing_right": 1, "amount_mismatch": 1, "exceptions": 3, "tolerance": 0.01 },
  "exceptions": [ { "kind": "amount_mismatch", "key": "C", "left_amount": 300, "right_amount": 250, "difference": -50 } ],
  "exceptions_truncated": false, "_schedule": { "previous_date": "2026-10-30" } }

The report is also stored: GET /api/reconcile/reports, /api/reconcile/reports/{id}, /api/reconcile/reports/{id}/csv, and Operations > Reconciliation in the editor (see Reconciliation reports). Test (dry run) computes the report from the live ledger but stores nothing and alerts nobody.

Typical nightly flow (payment gateway against the ERP):

webhook:doku  -> dedupe -> ledger(doku) -> map/transform -> http (NetSuite) -> ledger(netsuite) -> sink
schedule:recon-doku (30 0 * * *, Asia/Jakarta) -> reconcile(doku vs netsuite) -> http (email gateway)

Re-run a missed day by pushing a tick by hand: POST /api/ingest/schedule/recon-doku with {"_schedule": {"previous_date": "2026-10-28"}}. Deploy errors: bad names, a side that is neither ledger nor record, the same ledger side on both sides, a negative tolerance, an unknown time zone, max_exceptions out of range.

Error branches

Every node except a source has a second output port, error (the small red handle under the node). Wire it and a failure of that node no longer dead-letters the record: the node’s input record goes down the error wire instead, with _error added:

{ "from": "post_ns", "from_port": "error", "to": "notify" }
{ "...the record post_ns received...": "…",
  "_error": { "node": "post_ns", "kind": "http", "stage": "fail", "message": "HTTP 422 …" } }

stage is fail (repeating would not help), retry (transient: transport error, 408/429/5xx, a sink that refused the record) or timeout (node timeout). The error port exists on http, transform, sink, join and every other node; a record that is not an object is wrapped as { "_error": …, "value": … }.

  • Acknowledgement: the record settles with its error path, like any branch. If the error path ends Ack (it reaches a sink, or a filter drops it) the source message is acknowledged and not redelivered. If it fails, that failure decides.
  • A routed failure is not dead-lettered and raises no alert, but it is still counted on the node (err) and in engine.error_routed of GET /api/status.
  • Without an error wire behaviour is unchanged.
  • error_on on the node: all (default) sends failed, transient (retry) and timed-out records to the error wire. fail sends only permanent failures and timeouts; a transient failure still goes back to the source (NATS redelivery, 503 to a webhook sender). The http node has no retry of its own: its transient failures are exactly the retry stage.
  • The error frame is not a machine record: finish the error path with a transform that reshapes it before a sink.
{ "id": "post_ns", "type": "http", "url": "https://…", "method": "POST", "error_on": "fail" }

Compensation (undo steps)

A node option compensate names the node (of the same flow) that reverses it, for example the NetSuite reversal that undoes a journal posting. When a later step of the same record fails for good the invocation is rolled back:

{ "id": "post_je", "type": "http", "method": "POST", "url": "https://…/journalentry",
  "compensate": "reverse_je" }
{ "id": "reverse_je", "type": "http", "method": "DELETE", "url": "https://…/journalentry/${id}" }

reverse_je has no incoming wire: it only runs as a compensation (its own wires, if any, continue normally). compensate can also name a flow that has a Sub-flow input: it is called like a sub-flow.

Rules:

  • A step is completed when it emitted its record. Only completed steps are undone.
  • “Fails for good” = a failure that no error wire took over (an error wire handles it: nothing is rolled back). Deadline and step-limit kills do not compensate.
  • On such a failure the rest of the record’s queued branches is dropped and the undo steps run in reverse order of completion, once each, one after the other (a multi-node undo chain finishes before the next undo starts). Each undo receives what its step emitted plus _compensate: { step, failed_node, message }.
  • The source message settles only after the undo steps ran, with the failure’s verdict (Term for fail, Nak for retry/timeout: with Nak the record is redelivered and runs from the start).
  • Undo steps are listed in the trace (node kind compensate, in reverse order) and counted in engine.compensations. An undo that fails is alerted and does not stop the others. An undo step never triggers a further undo.
  • Test (dry run) performs the same sequence and records the side effects instead of sending them.
  • Deploy errors: a compensate that is not a node of the same flow (or a flow), names a source, or names the node itself.

Join (parallel branches)

A fork is a map that fans out its children, or one port wired to several nodes. A join node waits for every branch of the fork and emits one merged record.

{ "id": "both", "type": "join", "merge": "object", "pick": "metadata.http_response",
  "into": "metadata.results", "timeout_ms": 10000 }
Field Meaning
merge array (default): [result of each branch] in wire order. object: { "<feeding node id>": result }; two branches fed by the same node fail the join (use array after a map)
pick Dot path of what each branch contributes. Default: the whole branch record
into Dot path of the first branch’s record that receives the merged value, so the record keeps its shape. Default: the merged value becomes the record
timeout_ms Longest time from the fork to the join firing, 1 to 600000, default 10000. On expiry the join fails with stage: "timeout" (to its error port when wired)

Behaviour:

  • Branches run one after another in wire order on the record’s lane: the join is a barrier, not a second thread. Per-machine ordering is therefore unchanged: the next record of a machine starts only after this one (join included) finished.
  • The join fires when every other frame of the fork is done: arrived at the join, dropped by a filter, routed down an error wire, or failed. Nested forks join innermost first.
  • A branch that fails without an error wire ends the fork: the join emits nothing and the record settles with that failure (and compensates, if configured).
  • All arrivals settle together: the source message is acknowledged once the merged record’s path is.
  • Deploy warning: a join with nothing forking upstream (it would pass its one frame through).

Sub-flows

A flow tab can be called from another as a function.

  1. On the tab to reuse, add one Sub-flow input and one Sub-flow output (kinds subflow_in, subflow_out), connect them through the steps. The tab has no source: its nodes run only when called.
  2. Where it is needed add a Sub-flow call, calls set to the flow id of that tab.
{ "flows": [{ "id": "main", "name": "Main" }, { "id": "post_je", "name": "Post journal entry" }],
  "nodes": [
    { "id": "s", "type": "source", "flow": "main", "match": "webhook:doku" },
    { "id": "call", "type": "subflow", "flow": "main", "calls": "post_je" },
    { "id": "k", "type": "sink", "flow": "main", "sink": "push" },
    { "id": "in", "type": "subflow_in", "flow": "post_je" },
    { "id": "build", "type": "transform", "flow": "post_je", "script": "…" },
    { "id": "out", "type": "subflow_out", "flow": "post_je" } ],
  "edges": [
    { "from": "s", "to": "call" }, { "from": "call", "to": "k" },
    { "from": "in", "to": "build" }, { "from": "build", "to": "out" } ] }
  • The record enters at subflow_in; the record that reaches subflow_out continues out of the call node. Nothing else crosses: a sub-flow has no parameters beyond the record.
  • A call is compiled in: each call site runs its own copy of the steps with its own counters under <call id>::<node id> (the call node’s own counters count calls). Ordering, acknowledgement, join, compensate and error wires inside behave as in any flow. A failure inside a node that has no error wire of its own follows the error wire of the call node, if it has one.
  • Calls nest up to 8 levels. A flow that calls itself, directly or through others, is rejected at deploy (sub-flow cycle: a → b → a), as are a call to an unknown or disabled flow and a callee without exactly one connected input and output. Errors inside a callee are reported on the callee’s own node.
  • A flow that nobody calls gets a warning on its subflow_in.
  • A compensate naming a flow runs that flow as the undo step (see above).

A Link out sends the record to one or more Link in nodes on any flow tab, without a wire (kinds link_out, link_in). It is Node-RED’s link out / link in: a wire that may cross tabs.

{ "flows": [{ "id": "main", "name": "Intake" }, { "id": "ship", "name": "Shipping" }],
  "nodes": [
    { "id": "s", "type": "source", "flow": "main", "match": "webhook:orders" },
    { "id": "to_ship", "type": "link_out", "flow": "main", "links": ["from_intake"] },
    { "id": "from_intake", "type": "link_in", "flow": "ship" },
    { "id": "k", "type": "sink", "flow": "ship", "sink": "push" } ],
  "edges": [ { "from": "s", "to": "to_ship" }, { "from": "from_intake", "to": "k" } ] }
  • The targets are listed on the Link out (links, ids of Link in nodes). The Link in shows its senders. Pick them in the node’s form, or select the node: its other ends appear in a panel at the top right of the canvas, each with a Go button that opens that tab and selects the node.
  • A link is compiled into an ordinary wire. Everything a wire guarantees holds across tabs: fan-out order (the order of links), join, acknowledgement (the source message is acknowledged once everything behind the link has finished, and a failure there fails the record), error wires and traces. Counters stay per node. It is fire-and-forget: nothing returns to the Link out.
  • A Link out has no output wire and no error port; a Link in has no input wire.
  • Deploy checks: a target that does not exist or is not a Link in is an error; a disabled target, or a Link out with no target, is a warning. Links count as wires in the loop check, so a link back to an upstream Link in is rejected (links count as wires); the executor’s step limit remains the runtime backstop. Link nodes cannot be placed on a sub-flow tab.
  • Deleting a Link in removes it from every Link out that pointed at it.

Link or sub-flow? A link continues the record on another tab and never comes back (like a wire). A sub-flow call runs another tab as a function: the record goes in, the result comes back and continues out of the call node. Use a link to split one long flow over several tabs or to share an “after processing” tail; use a sub-flow to reuse steps. (Node-RED’s link call is the sub-flow call.)

Groups

Select nodes and press Ctrl+G to put them in a named, coloured box. Drag its title bar to move every node, drop a node inside the frame to add it, fold it with the chevron, and ungroup with Ctrl+Shift+G. A group is a label: it does not change how records flow.

It is stored in flow.json as UI metadata that the engine ignores and returns unchanged: groups: [{ "id", "name", "color", "collapsed" }] on the flow tab, and "group": "<id>" on each member node. The box is always the bounding box of its members. Groups follow copy and paste (also across tabs), duplicate flow, import and export, and are co-edited like any other field.

Editing the canvas

  • Quick add. Drag a wire from an output port and release on empty canvas, double-click empty canvas, or Ctrl+click it: a searchable popover opens at that point. After a wire it ranks what usually follows that node and port (a source suggests filter, transform, validate, skip duplicates; an error port suggests an output, an approval, an email, an HTTP request), then lists the same groups as the palette, in the same order and narrowed by its Plant / Business switch. Typing searches every entry whatever the switch says and shows each hit’s group. Enter adds the node there and wires it.
  • Splice into a wire. Drag a palette tile onto a wire, or a node that has no wires onto one, and release: the wire runs through the node. Hold Alt to move a node over a wire without splicing. Shift+Del deletes a node but keeps the wire through it; Detach wires cuts a node loose and keeps it.
  • Selection bar and right-click menu. Group, disable, align and distribute, detach, save as snippet, export, delete; right-click a node or the canvas for the full menu (select what a node feeds or what feeds it, paste here, insert a snippet).
  • Find. Ctrl+F searches nodes by name, type, id and any setting (a URL, a script, a table name) and jumps to the match on whichever tab it lives.
  • Copy and paste across tabs. Copy on one tab, switch tab, paste: groups and the wires between the nodes come along, ids are renewed and source matches cleared (a match is unique). The copy also travels through the system clipboard to another browser window.
  • Snippets. Save the selection to the library (kept in this browser), insert it anywhere, export and import the library as a file. Export selected nodes writes a flow file the Import dialog takes.

Coming from Node-RED

Node-RED OpexFlow
Flow tabs, tab menu Flow picker on the canvas: rename, duplicate, disable, export, service level, delete.
Wires, link in / link out Wires; Link out / Link in (above).
Link call Sub-flow call (above).
Subflows Sub-flow input / output tabs, called by Sub-flow call; compiled in per call site.
Groups Groups (above).
Quick-add (Ctrl+click) Quick add, also on a dropped wire, ranked by what usually follows.
Catch node The error port on every node.
Debug node and sidebar Debug panel: live stream, watch chosen nodes, filter text, a debug tap on any node.
Inject node Inject sources (play button on the node, I), schedule and webhook sources.
Node status text Live throughput / latency / last error line under every node.
Info sidebar Edit panel: node id, type, help card, wires, settings.
Palette manager (install nodes) Not applicable: one binary with the node set built in.
Context data (flow / global) Not applicable: records are the state; use ${var:NAME} variables, lookup tables, the ledger.
Deploy modes (full / modified) One atomic Deploy with a pending-change diff, versions and rollback.

Matching records to sources

Each record picks its source node by its route key {source}:{machine.id}:

  1. an exact source node, modbus:PLC-01;
  2. the adapter wildcard, modbus:*;
  3. the catch-all, *.

match is unique across source nodes. A record that matches no source node is counted as unrouted (shown in the status pill) and dropped.

A schedule:{id} source is not matched by any adapter: it is driven by its own cron and timezone and emits one record per tick (see Schedule source).

The palette

The palette groups every node by who uses it, so a plant engineer never has to read past finance nodes and a business integrator never has to read past printers.

Group For Holds
Machine inputs Plant Live device sources (Modbus, OPC-UA, EtherNet/IP, FOCAS, MTConnect, MQTT, PI), their type:* wildcards, the catch-all
Business inputs Business Schedule, Signed webhook, HTTP ingest, App action; live HTTP poll, NATS and database sources
Test inputs Both Live Inject sources (generated test records); the group is hidden while there are none
Shape data Both Transform (JavaScript), Transform (rules), Filter, Branch, Split list, Merge branches, Skip duplicates
Machines and lines Plant Printer, Verify scan, and your printers grouped by line
Business systems Business HTTP request, SOAP call, SQL query, SFTP upload, Send email
Finance and data quality Business Business event, Validate, Lookup, Ledger, Reconcile, Approve
Outputs Both Live dashboard, Local history, Push to app, NATS, MQTT, ClickHouse, Postgres
Structure Both Sub-flow call, input and output, Link out and in, Comment

Every node is in exactly one group. Snippets (your saved fragments) sit below the groups.

  • Plant / Business / All. The switch at the top hides the groups that do not apply: Plant hides the business groups, Business hides the machine groups. All is the default. A hidden group stays listed, marked in use, while the flow open on the canvas has a node from it, so switching never hides what you are working with. The choice is stored per signed-in user in the browser.
  • Search. The search box always covers every group, whatever the switch says. It opens every group with a hit, lists each hit under its group, and matches names, descriptions, group names and the earlier names below.
  • Folding. Click a group header to fold it; the palette remembers which groups are folded, per user.
  • Adding. Drag a tile onto the canvas or a wire, or click it, exactly as before.

Earlier palette names, still found by search:

Earlier name Now
Transform JS / Transform DSL Transform (JavaScript) / Transform (rules)
Map Split list
Join Merge branches
Dedupe Skip duplicates
Event Business event
Verify Verify scan
HTTP / SOAP / SQL / Email / SFTP HTTP request / SOAP call / SQL query / Send email / SFTP upload
Printers (bulk) Printer
Schedule (cron) Schedule
Sinks hub, storage, push Live dashboard, Local history, Push to app

The node kinds in flow.json (map, join, dedupe, …) did not change; only the names shown in the editor.

An SFTP folder or mailbox source (sftp:{id}, imap:{id}) has no palette tile, because the editor has no form for its connection settings yet: bring it in with an imported flow file, and it counts as a business input once on the canvas.

Layout

  • Top bar: connector name, live status (connected or stale, adapters up, push backlog, unrouted count), search (Ctrl+K jumps to a node or source, or runs an action), the Deploy button with its pending-change count, and the theme / sign-out menu.
  • Palette (left): a Plant / Business / All switch, then the groups of The palette: inputs (live sources with status dots, the adapter wildcards, the catch-all, schedule, webhook, ingest and app action), the processing nodes, your printers by line, and the outputs with their delivery status. Add next to the inputs creates a runtime source on the connector.
  • Canvas: each node shows live throughput and latency under it (12/s · 3ms) or its last error. A connection animates only while records crossed it in the last two seconds. Inject sources have a play button on the node.
  • Sidebar (right, resizable, switched with the icon rail): Edit the selected node (script editor, AI generation, Test with last record), Debug the live record stream, Problems (validation findings plus dead letters), Metrics (needs a telemetry build).

Press ? in the editor for the keyboard cheat sheet.

Deploying

Edits stay in the browser until you press Deploy (Ctrl+S). The connector validates and compiles every source first, writes flow.json atomically, and only then swaps all running pipelines in one step. A rejected flow (HTTP 422) leaves the running state untouched, and the errors are listed in Problems with a click-to-focus link to each node. If someone else deployed meanwhile (HTTP 409) the editor offers to reload their version or overwrite it with yours.

On the first start after upgrading, the flow is generated from the old transform: config and pipelines.runtime.json, with identical routing. The canvas shows a notice until you Deploy once; from then on flow.json is authoritative and the transform: routes in config.yaml are ignored.

Testing a node

Select any node and press Test with last record. The connector runs the draft flow from the source feeding that node using the newest real record it has seen for that source (or a JSON record you paste), with no side effects. You get every step with its status and duration, an input-to-output diff for the selected node, and the records each sink would have received.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close