Flows

A flow is a small program you draw instead of write. It's a graph of steps that read and write items in any of your lists, check who's asking, branch, loop over arrays, and respond. JSONPad runs it for you, so you don't need a server of your own for it.

A flow starts in one of three ways:

  • As an endpoint, called by your app at https://api.jsonpad.io/flows/<path>. Everything it writes happens together, or not at all, and it responds with whatever you choose.
  • After an item event: shortly after items are created, updated, restored or deleted in the lists it watches. See event flows.
  • By hand, from the dashboard.

Flows are for the things your app can't safely do from the browser: writing to two lists at once, taking stock only if there's enough, dealing the top card of a deck the players can't see, or keeping a count up to date. A flow runs with your privileges, as a trusted command, and decides for itself who may do what.

A first flow

This endpoint creates an order and takes the stock for it. If there isn't enough stock, or nobody is signed in, it refuses, and nothing is written. It's one of the templates on the Flows page in the dashboard.

Each box is a node, and runs after the nodes with edges into it. product reads an item, in_stock checks something is true, order and take_stock write, and out responds. A flow is stored as a JSON document, which is what the editor's JSON tab shows:

{
version: 1
name: "create-order"
description: "Pair with a rule on orders like allow create: "server" in token.tags"
entry: {
type: "endpoint"
method: "POST"
path: "create-order"
input: {
type: "object"
required: [
0: "productId"
1: "quantity"
]
properties: {
productId: {
type: "string"
}
quantity: {
type: "integer"
minimum: 1
}
}
}
}
nodes: [
0: {
id: "signed_in"
type: "require"
when: "identity != null"
status: 401
message: "sign in first"
}
1: {
id: "product"
type: "get-item"
list: "products"
item: "input.productId"
required: true
}
2: {
id: "in_stock"
type: "require"
when: "steps.product.data.stock >= input.quantity"
status: 409
message: "not enough stock"
}
3: {
id: "order"
type: "create-item"
list: "orders"
identityId: "identity.id"
data: "{ productId: steps.product.id, quantity: input.quantity, total: steps.product.data.price * input.qu..."
}
4: {
id: "take_stock"
type: "update-item"
list: "products"
item: "steps.product.id"
merge: "{ stock: steps.product.data.stock - input.quantity }"
ifVersion: "steps.product.version"
}
5: {
id: "out"
type: "respond"
status: 201
body: "{ orderId: steps.order.id, total: steps.order.data.total }"
}
]
edges: [
0: [
0: "signed_in"
1: "product"
]
1: [
0: "product"
1: "in_stock"
]
2: [
0: "in_stock"
1: "order"
]
3: [
0: "order"
1: "take_stock"
]
4: [
0: "take_stock"
1: "out"
]
]
}

Every value a node needs is an expression, written in the same language as write rules: steps.product.data.stock >= input.quantity. The flows reference lists every node and what it takes.

How a flow runs

  • Order. Nodes run in the order the edges give. A node runs once every node before it has finished, if at least one edge into it was taken. A flow can't loop back on itself: to repeat something, use a for-each node, whose body is a small graph of its own that runs once for each element of an array.
  • Branches. A condition node has two outputs, true and false, and only the edges from the one it chose are taken. A switch has an output for each case. Nodes on a branch that wasn't taken are skipped, and their steps.<id> is null.
  • Results. Each node's result is steps.<id> to the nodes after it: the item a get-item read, the array a find-items found, the value a compute worked out.
  • Stopping. A require node stops the flow unless its expression is true, with the status and message you give it. So does anything else going wrong: an item that isn't there, a write your list's JSON schema refuses, running out of time.
  • All or nothing. A run is one database transaction. If it stops, every write it made is undone, and nothing it emitted is sent. If two runs change the same items at the same moment, one of them is run again from the start, so two players can never both be dealt the top card.

Expressions can read:

  • inputThe request body (or query parameters for a GET), the event for an event flow, or what you gave a run by hand.
  • stepsThe results of the nodes before this one, by id.
  • identityThe identity calling the flow (or making the change, for an event flow), or null.
  • tokenThe API token calling the flow: its id and tags.
  • nowThe time the run started. Every write in the run uses it.
  • flowAbout the run: name, runId, eventId, depth and dryRun.

A flow can also declare helpers: let and function declarations every expression in it can use.

1function isPlayer(game) = identity != null
2 && identity.id in game.data.playerIds;
3
4let maxQuantity = 10;

What a flow can do

A flow reads and writes any of your lists, as you. It isn't limited by the permissions of the token that called it, it can read guarded values, and its writes skip your lists' write rules unless a node asks for them with applyRules. It sees activated lists and items only, and its writes do everything the item endpoints do: variables like $jsonpad-var:now, JSON schemas, indexes and aliases, your plan's limits, events, realtime updates and webhooks.

So a flow is a trusted command, and it does its own authorisation with require nodes: check identity, or token.tags, before it does anything that matters. The editor warns you about an endpoint flow that never looks at either.

That gives you the command pattern. Clients can't write to orders directly, because its write rules only let your server's token create orders:

1allow create: "server" in token.tags;

...so the only way a client can create an order is to call the create-order flow, which checks the stock and takes it in the same transaction.

Calling an endpoint flow

An endpoint flow answers one method at one path: POST /flows/create-order, for example. Call it with one of your API tokens, and optionally an identity. The token needs the run permission on the flow, or run-with-identity with an identity. If the flow's entry has an input schema, input that doesn't match it is refused before the flow runs.

cURL
12345curl https://api.jsonpad.io/flows/create-order \ -H "Content-Type: application/json" \ -H "x-api-token: <YOUR TOKEN>" \ -H "x-identity-token: <IDENTITY TOKEN>" \ -d '{ "productId": "7b27d73e-2243-4954-99ed-a8c4d5d9b2d9", "quantity": 2 }'

The response is whatever the flow's respond node chose: its status, body and headers. A flow without one responds 204 No Content. A flow that stops responds with the error status it stopped with, e.g. a require node's 409:

{
name: "FLOW_FAILED"
code: 23008
message: "not enough stock"
details: {
code: "REQUIRE_FAILED"
node: "in_stock"
}
}

Every response has an x-flow-run header, the run's id, to find it in the flow's run log.

Public flows

An endpoint flow with public turned on can also be called without a token, at /flows/public/<flow id>: a contact form, a sign-up list, a vote. Its require nodes are all that decides who may do what, so write them with care. Each run counts against your allowance, and one address can call a public flow at most 30 times a minute.

Building a flow

Open Flows in the dashboard, and create one, or start from a template. The editor has the graph on one side and, on the other:

  • Node and Flow: the selected node's settings, or how the flow starts, its input schema and its helpers. Expressions are checked as you type.
  • Test: run the flow as it is in the editor, against your real data, with any input and as any identity. Everything it does is rolled back afterwards, nothing is emitted, and the run isn't logged, so you can try it as often as you like. Each node on the graph is coloured by what it did.
  • Tests: the flow's stored tests (below).
  • JSON: the whole document, to edit or copy.

Flows are part of your account's configuration, so they're managed by you, in the dashboard (or with schema sync), never with an ordinary API token.

Testing flows

A flow can have stored tests. Each gives the flow an input (and an identity, and the items it needs), runs it against an in-memory copy of those items, and checks what happened: the status and body, the error, the writes, or the items afterwards. The tests run every time the flow is saved or synced, and a flow whose tests fail isn't saved.

create-order.tests.json
12345678910111213141516171819202122232425262728293031323334{ "identities": { "alice": { "id": "alice-id", "name": "alice" } }, "items": { "products": { "widget": { "data": { "price": 5, "stock": 3 } } } }, "tests": [ { "name": "takes the stock and responds with the order", "identity": "alice", "input": { "productId": "widget", "quantity": 2 }, "expect": { "status": 201, "body": { "total": 10 }, "items": { "products": { "widget": { "stock": 1 } } } } }, { "name": "refuses more than there is", "identity": "alice", "input": { "productId": "widget", "quantity": 4 }, "expect": { "status": 409, "error": "not enough stock", "writes": [] } }, { "name": "needs someone signed in", "identity": null, "input": { "productId": "widget", "quantity": 1 }, "expect": { "status": 401, "error": "sign in first" } } ] }

Test items are keyed by an id you choose, like widget, and that's the id the flow sees. An expectation only has to mention what matters: bodies and items match if they have at least the fields given.

Keeping flows in git

A flow is a JSON document, so it can live in your repository next to your schema sync document, which declares it along with the lists it uses. The command line tool checks and tests flows without the network, and syncs them with the rest of your schema.

jsonpad-schema.json
1234567891011121314151617{ "$schema": "https://jsonpad.io/schema/sync-v1.json", "scope": "shop", "lists": { "products": { "name": "Products" }, "orders": { "name": "Orders", "rules": "allow create: \"server\" in token.tags;" } }, "flows": { "create-order": { "flowFile": "flows/create-order.flow.json", "flowTestsFile": "flows/create-order.tests.json" } } }

Where the nodes sit in the editor isn't part of what's compared, so tidying a flow up in the dashboard doesn't make the file out of date. Syncing flows needs a token that is allowed everything, because a flow runs with your full privileges.

The run log

Each flow's page in the dashboard shows its last 100 runs: when, how it started, how it ended, how long it took and what it cost. Select a run to see which way each node went on the graph, and its input. The log never holds your items' data: only whether each node ran, and the values that were true or false.

Limits and cost

A flow costs what the same API calls would have: calling it is one request, and each item it reads or writes is one more. Runs you start in the dashboard, and test runs, are free. Each plan allows a number of flows: 2 on Free, 10 on Indie, 50 on Pro and 250 on Scale.

These limits are the same on every plan:

  • 100 nodesper flow, counting the nodes in a for-each's body once
  • 50 reads and writesper run, of which at most 25 writes
  • 50 elementsper for-each, each time it runs
  • 5 secondsfor an endpoint flow, and 30 for an event flow

The reference has the rest.

Flows don't call other services yet. To tell your own server about something a flow did, use an emit node, which sends a signed event to one of your webhooks once the run has committed.

2026-09-27