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_inruns 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 with401 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 exampleinvalid_client) is a failure. The token endpoint host must be inhttp_host_allowlisttoo. -
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-bearerandclient_assertion=<JWT>. Header{alg, typ: "JWT", kid}withkid=key_id(the Certificate ID NetSuite shows after you upload the public certificate in Setup > Integration > OAuth 2.0 Client Credentials (M2M) Setup); claimsiss=client_id,scope(array, fromscope, space or comma separated),aud= the token endpoint (oraudience),iat,exp(lifetime_secs, default 300, at most 3599; Oracle requires under 60 minutes). Signing usesring: RSA-PSS (PS256/PS384/PS512) or ECDSA (ES256P-256,ES384P-384).private_keyis 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 fromopenssl ecparam -genkeyis SEC1, convert it withopenssl pkcs8 -topk8 -nocrypt. Encrypted keys are not supported. The access token is cached and refreshed on expiry and once after a 401, exactly likeoauth2_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.methoddefaults toPOST;headersandbody(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 containskey,secret,pass,token,authorsignaturemust contain such a reference: a literal is a deploy error. The token is read from the JSON response attoken_path(data.accessToken; array indexes are numbers). Lifetime:expires_in_path(seconds) when the response has it, elsettl_secs, else one hour. A 401 drops the token and repeats the request once with a new one. Withrefresh_url+refresh_token_paththe new token is first requested with the refresh token (refresh_method, defaultGET,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 carriesClient-Id,Request-Id(a new UUID),Request-Timestamp(UTC,2026-11-03T02:47:11Z) andSignature: HMACSHA256=+ base64(HMAC-SHA256(secret key, component)). The component is the same text the DOKU notification check uses, withRequest-Target= the request path (no host, no query) andDigest= base64(SHA-256(exact body bytes)); a request without a body (aGET) has noDigestline. 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 theoauth_*parameters (the JSON body is not part of the signature base).realmis the account id, for a sandbox1234567_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_SECRETNETSUITE_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 afterwait_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": answers200 {"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.dband 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
dupcounter (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_seenas a side effect and the record continues. - Deploy errors: a key without a placeholder or with an unterminated
${, a badttl, 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 inengine.error_routedofGET /api/status. - Without an
errorwire behaviour is unchanged. error_onon the node:all(default) sends failed, transient (retry) and timed-out records to the error wire.failsends 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 theretrystage.- 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
errorwire 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 forretry/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 inengine.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
compensatethat 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.
- 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. - Where it is needed add a Sub-flow call,
callsset 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 reachessubflow_outcontinues 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,compensateanderrorwires inside behave as in any flow. A failure inside a node that has no error wire of its own follows theerrorwire 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
compensatenaming a flow runs that flow as the undo step (see above).
Link nodes
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),errorwires 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
errorport; 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
matchis 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}:
- an exact source node,
modbus:PLC-01; - the adapter wildcard,
modbus:*; - 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
telemetrybuild).
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.