Flows and Resolution
A fraud decision does not come from a fixed rule set — it comes from the flows you publish. You scope a flow by operation type (card payment, bank transfer, wallet withdrawal…); when a decision request arrives the system resolves every matching published flow, runs them and merges the results into a single decision.
This page explains how resolution works — the answer to "which flows did my request land on?". For the endpoint contract see Decision API.
The endpoint that triggers your rules
The flow you published — and the rules inside it — are triggered by a single call:
POST /api/v1/fraud/decisions/evaluate
Authorization: Bearer <access-token>You mint the token from POST /api/v1/auth/token with your Ed25519 signature. Required scope: fraud:view_decisions. Full field dictionary: Decision API.
Which request field drives which dimension
You steer the resolution — which flows run is derived from what you send:
| Resolution dimension | Where it comes from |
|---|---|
merchantId | From your token — you do not send it in the body |
merchantGroupIds | From your token context |
subjectType | The operation type in the body (payment, bankTransfer, billPayment … whichever object you fill) |
productContext | In the body; defaults to direct_api |
integrationMode | In the body |
| Flow name | metadata.fraudFlowName (or liveFraudFlowName / flowName) |
curl -X POST "https://api-gateway.lunixi.com/api/v1/fraud/decisions/evaluate" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"bankTransfer": { "amount": 250000, "currency": "TRY", "iban": "TR33…" },
"customer": { "id": "cust_84213" },
"productContext": "direct_api"
}'This call finds every flow you scoped to the bank_transfer operation type, runs them and returns the merged decision. Which flows ran and which rules matched come back in the explainability fields and the multiFlow block — that is where you read why the decision was made.
For history-based rules (velocity, baseline deviation) you must also feed events: POST /api/v1/fraud/events/transactions (single) and /batch. The decision call looks at one transaction; "this customer made 12 transactions in the last 24 hours" comes from the event backbone. Those endpoints are Ed25519 signed, not Bearer.
The dimensions a flow is scoped on
A flow can be narrowed on the dimensions below. A dimension left empty means "any" — it matches every value on that axis.
| Dimension | Example | If left empty |
|---|---|---|
merchantId | A specific merchant | Global flow — matches every merchant |
merchantGroupIds | A list of merchant groups | No group matching |
flowType | fraud_decision | — |
subjectType | card_payment, bank_transfer … | Matches every operation type |
integrationMode | Integration mode | Matches all |
productContext | direct_api (default) | Matches all |
subjectType carries the operation type: card_payment · bank_transfer · eft · fast · crypto_transfer · wallet_deposit · wallet_withdrawal · wallet_payment · wallet_transfer · bill_payment · loan · payout · refund · cash_in · cash_out · international_transfer
Only published and isActive flows enter resolution. A draft or inactive flow is never considered, however narrow its scope. On the decision path, only triggerType='inline_request' flows run: flows triggered by events, kafka or a schedule run on their own triggers and never enter the decision path — otherwise the same flow would execute twice.
Resolution: every matching flow runs
The scope dimensions are unchanged — merchantId · merchantGroupIds · flowType · subjectType · integrationMode · productContext (an empty column matches everything). What changed is the selection step: the system no longer picks the single narrowest flow. Every published flow that passes the filter is resolved and runs in one of two lanes, chosen by its executionMode: sync flows produce the decision response, async flows run in the background without delaying the decision.
sync ones merge into the decision, the async ones run in the background without delaying it.The sync lane: independent runs, one decision
Every executionMode='sync' flow runs independently on a zero base state: flow order does not affect the result, and no flow sees another flow's score or reason codes. Once they finish, the outcomes are merged into a single decision:
| Dimension | Merge rule |
|---|---|
| Decision | The most severe wins: allow < force_3d < review < block |
| Score | A per-flow cap (default 100) plus a total cap (default 250). When a cap kicks in, the response carries the multi_flow_score_capped reason code |
| Flow failure | The other flows keep running; the failed flow enters skippedFlows and the decision is escalated to at least review (reason code flow_failed_fail_closed:<flow-name>). An existing block is never downgraded |
| Time budget | Default 25 s per request. When the budget runs out, remaining flows do not run and are listed in skippedFlows with reason budget_exceeded |
Three levels of time limit
The time budget is not one layer but three nested limits, and the outer one bounds the inner:
| Level | Default | If exceeded |
|---|---|---|
| Node | 8s | That node errors |
| Flow | 20s | That flow is skipped and lands in skippedFlows |
| Request (all flows) | 25s | Remaining flows do not run — budget_exceeded |
If a node sets its own timeoutMs it is used — but it cannot exceed the flow deadline; the node is bounded by whatever time is left.
A node timeout is a node error. With continueOnError set it takes the error branch; without it, the flow's fail-closed posture applies and the decision rises to at least review.
The practical consequence: a slow external service behind an http_request or grpc_call node can affect not just that node but the whole decision. Giving outbound nodes their own timeoutMs puts that behaviour under your control.
A flow that did not run is not allow. A flow skipped over budget is distinguishable from a flow that ran and came back clean through the skippedFlows field — when you build monitoring, count the budget_exceeded reason separately.
The flowPath in the explainability output is now flow-prefixed: each step comes as flow:<flow-name>/<node-id> — you can read from the response which node belongs to which flow.
The async lane: never delays the decision
executionMode='async' flows do not hold up the decision response: the run is queued, shows up in the response's asyncFlows list with status queued, and executes in the background on the same engine. The result is written to its run record — see Async result delivery below.
An async result is advisory: it never retroactively changes the decision that was already returned synchronously. The flow's side effects — opening cases, running actions — apply normally.
The multiFlow block in the response
The decision response gained an additive field — no existing field changed:
{
"multiFlow": {
"enabled": true,
"executedFlows": ["card-velocity-rules", "device-reputation"],
"skippedFlows": [
{ "name": "heavy-analysis", "reason": "budget_exceeded" }
],
"asyncFlows": [
{ "name": "case-sweep", "status": "queued" }
]
}
}On the gRPC wire the same block travels as a JSON string in the multiFlowJson field.
Calling a specific flow by name
If the request carries a flow name in metadata, resolution locks onto that name — multi-flow resolution runs only the named flow for that request:
{ "metadata": { "fraudFlowName": "yuksek-riskli-havale" } }Accepted keys: liveFraudFlowName, fraudFlowName, flowName. This is how you exercise canary or trial flows — do not use it to override the default in production.
Async result delivery
The result of an executionMode='async' flow is written to its run record.
Delivery of those results to you will run on platform webhook events — coming soon. For platform webhook setup, signature verification and retry behaviour, see Webhook setup.
Missing/malformed data in switch nodes: condition vs op
A switch case can be written in two forms, and their behaviour on missing or malformed operands (e.g. text where a number is expected) is deliberately different:
| Case form | On missing/malformed operand | Why |
|---|---|---|
condition (full rule DSL) | The case counts as matched and that branch is taken (fail-closed) | Rule-engine security doctrine: a type-manipulated request must not silently bypass a block-side control |
Simple op value match (eq, gt, in...) | The case is skipped, the flow takes the default branch, and it is surfaced in indeterminateCases (fail-safe) | Cheap routing matches should not lock the flow on missing data — but it must stay visible |
Flow-author rule: write decision-critical (block-side) checks with condition —
you want fail-closed. Write cheap routing matches with op — missing data falls to
default and stays visible via indeterminateCases.
Inbound webhook trigger (triggerType: webhook)
A webhook-triggered flow is fired from outside through an event-stream
connection profile. The chain:
- Define a connection profile in the panel (source:
webhook; the signature secret lives on the profile). - The external system POSTs to
POST /api/v1/fraud/database-events/{connectionProfileId}/webhook/{routingKey}— the signature travels in thex-event-signatureheader, the timestamp inx-event-timestamp. - The signature is verified (HMAC + time window); then every published flow whose
triggerTypeiswebhookoreventand whosetriggerConfig.webhookPathequals theroutingKeyis executed.
This inbound trigger only runs the flow; its signature secret lives on the connection profile.
Choosing an executionMode
| Mode | Use it for |
|---|---|
sync | Fast checks that shape the decision itself — velocity rules, thresholds, list checks |
async | Heavy analysis, reporting and case flows — anything that must not add to decision latency |
When nothing matches
An unconfigured merchant does not silently get allow. If no capability bundle, rule group or flow resolves, the request is tagged with the fraud.decision.unconfigured reason code and falls back to the policy default (FRAUD_UNCONFIGURED_DECISION, defaulting to review).
This is deliberate: the earlier behaviour could not tell an unconfigured merchant apart from an explicitly cleared payment. Watch the fraud.decision.unconfigured reason code while you integrate — you will see it in production if your flow never went live.
Capability bundles
Flows also run on their own, but there is a capability bundle layer above them: a bundle describes which capabilities run for a decision, in what order and under which failure policy, orchestrating several flows where needed.
| Situation | What runs |
|---|---|
| A capability bundle resolves for the merchant | The bundle runs and orchestrates the flows |
| No bundle | The matching published flows run directly under multi-flow resolution |
| Neither | fraud.decision.unconfigured → policy default |
If a flow errors mid-run the decision fails closed: the evidence gathered so far (capability, rule groups, live signals) stays valid and the decision is escalated to at least review — an existing block is never downgraded.
Running your own code inside a flow
Rule nodes are enough to express a threshold or a comparison, but some policies cannot be written in a rule language — computing a score with your own formula, parsing an external response, a transformation specific to your business. For those you place a script node in the flow and run your own JavaScript.
Script asset lifecycle
| Stage | What happens |
|---|---|
| Draft | Source code, input/output schema, time and memory ceilings are defined |
| Static analysis | A mandatory pre-publish gate. Patterns such as import / export, require() and eval are blocked. Result: clean · warning · blocked |
| Published | Only code that passes analysis can be published; a source digest (sourceHash) is recorded |
| Disabled | Withdrawn from publication; flows can no longer call it |
How it runs
The code does not run inside the fraud service but in a separate execution service: inside a real V8 isolate (isolated-vm), with its own heap ceiling and its own time limit. Isolates are recycled after a set number of jobs, and output and log sizes are capped.
Your script cannot reach the network, touch the file system, or load another module. If it needs outside data, fetch it with the flow's own nodes (http-request, database-lookup, redis-get) and pass it in as input.
Two modes
| Mode | Use |
|---|---|
sync_transform | Pure transformation — take input, return a value. Preferred on the hot path |
async_script | Longer-running logic that needs to await something |
Every execution is audited: success · failed · timeout. A script that exceeds its time ceiling does not stall the flow — it returns as an error and the flow's fail-closed posture takes over.
Related
- Decision API — the
POST /api/v1/fraud/decisions/evaluatefield dictionary - Decision logs —
GET /api/v1/fraud/decision-logs