A flow engine with a memory.
Bindato runs trigger→action flows between Shopify and the systems around it, keeps a record of every step, and ships each integration as a bundle with settings the merchant confirms before anything runs.
- Shopify
- NetSuite
- BrightPearl
- Cin7
- Connectors
- 7
- Flow templates
- 6
- Tenant-scoped tables
- 12
Flow engine
Trigger → action, with the failure modes decided up front.
A flow starts from a connector trigger and walks a graph of steps: actions against the other system, conditionals that branch on the record, and a split that fans a list into per-item child runs.
- Transient errors — 429, 500, 502, 503 — retry up to 5 times with exponential backoff (250 ms doubling, capped at 30 s). Permanent errors stop the flow at that step and record why.
- A split step fans out to at most 10,000 child runs, enqueued in chunks of 500. Over the ceiling the run fails loudly rather than processing the first ten thousand and reporting success.
- Each step's error policy is its own: halt, retry, or continue and skip the record.
Recent runs
| Flow | Status |
|---|---|
| Shopify order → NetSuite sales order | Success |
| NetSuite fulfillment → Shopify fulfillment | Success |
| NetSuite inventory → Shopify inventory level | Partial |
| Shopify order → BrightPearl sales order (split by location) | Failed |
| BrightPearl goods-out shipped → Shopify fulfillment (per location) | Success |
The step vocabulary
Trigger
A connector event — webhook or poll — that starts a run with the record as its payload.
6
triggers arrive as the event happens
5
triggers fetch on a schedule with a cursor
6
of 7 connectors can start a flow
Action
A call into the other system with a field mapping and transforms (toString, toNumber, trim…).
Conditional
Branches the run on the record: fulfil only when every line has a SKU, for example.
Split
Fans a list into per-item child runs, each with its own record and retry.
10,000
child runs per split, then the run fails loudly
500
items per enqueue chunk
Retry policy
Per step: halt, retry with backoff, or continue and skip the record.
5
retries on 429, 500, 502, 503
30 s
backoff cap, from 250 ms doubling
Reprocess
Re-enqueue a failed run, optionally with an edited payload, from the step that failed.
Bundles and the Settings tab
An integration you install, then configure — never the other way round.
A bundle is a pair of connectors and a set of flow templates. Installing it clones those flows into your account and blocks them until its Settings tab is confirmed section by section.
- A section can be gated by a master toggle — inventory sync switched off means no warehouse mapping is demanded of you.
- Dropdowns are backed by live reference data from your connected accounts: your warehouses, your price lists, your order statuses. Fixed lists are used only where the vocabulary genuinely is fixed.
- Mapping tables carry per-table uniqueness rules — a payment method maps once; several carriers may feed one — and are validated on the server before a save is accepted.
Shopify + BrightPearl · Settings
- ConfirmedGeneral2 settings
- ConfirmedInventory4 settings · gated by a master toggle
- ConfirmedPrices2 settings · gated by a master toggle
- ConfirmedOrders24 settings · gated by a master toggle
- ConfirmedFulfillment2 settings · gated by a master toggle
- Needs reviewPayments2 settings
- Needs reviewRefunds6 settings · gated by a master toggle
Run records and error handling
Every run is a record you can open to the step that failed.
A run keeps a row per step with the input it received, the output it produced and how many retries it took. Errors are a queue of their own, joined to the failing step so the payload sits beside the message.
- Resolve, ignore, or reopen an error; reopening clears the resolution rather than stamping a new one.
- Retry re-enqueues from where the run actually began — a split child restarts at its own item, never at the top of the parent flow.
- Edit the raw payload and reprocess when the fix is in the data, not the mapping.
Run 01J9…K3Q
Shopify order → NetSuite sales order
- 12 mstriggertrigger
order.created · webhook, signature verified
- 1 mshas-skuconditional
every line item carries a SKU → true
- 840 mscreate-sales-orderaction
createSalesOrder · 422 after 1 attempt (not retryable)
{ "externalId": "5827113", "currency": "GBP",
"customerEmail": "", "lineItems": [ … 3 ] }
→ 422 customerEmail is required- Edit payload & retry
- Resolve
- Ignore
Connector SDK
One contract every connector implements — including Shopify.
A connector declares its auth, triggers, actions and reference data as typed metadata. The catalog, the flow builder and the connection screens work from that metadata alone, so a new system is a package, not a fork.
- Triggers are webhooks or polling. A webhook trigger with no signature check must declare fetchOnNotify — the registry rejects it at startup otherwise — so the payload is a pointer and the record is re-fetched by id before any step acts on it.
- Actions carry their own input and output JSON Schemas and an optional per-action rate limit — one marketplace's order read allows about a call a minute while its shipment confirm allows five a second, which is why the limit lives per action, not per connector.
- referenceData lists — warehouses, channels, price lists — are what make Settings dropdowns real instead of hand-typed ids.
interface Connector
id, label, authType oauth2 | api_key | one auth config per connector |
triggers[] webhook | polling | each with a JSON Schema output |
actions[] run(input, creds) | input + output schema, per-action rate limit, idempotency key |
testConnection → ConnectionHealth | runs when a connection is saved and on schedule |
refreshCredentials? → Partial<creds> | null | throws → connection marked needs_reauth |
referenceData? list(creds) → items | warehouses, price lists, channels for Settings dropdowns |
- verifySignatureFnSigned webhook
HMAC verified against the raw body before anything is enqueued.
- fetchOnNotify: trueUnsigned webhook
Payload is treated as a pointer only. The engine forces fetchOnNotify and re-reads the record by id from the source API.
- intervalMinutesPolling
A cursor-based fetchFn on an interval, for APIs with no webhooks.
Multi-tenant architecture
Isolation enforced by the database, checked at boot.
Every request resolves to one tenant before a query runs, and every query runs inside a transaction that carries that tenant id to Postgres, where row-level security does the filtering.
- 12 tenant-scoped tables have RLS enabled and FORCED, each with a tenant_isolation policy, so the table owner is bound too.
- The API refuses to start if any scoped table is missing RLS, is not forced, or has no policy — the guarantee is verified every deploy, not assumed once.
- The app connects as its own role, never the superuser; an admin bypass cannot leak through the application path.
One request, four checks
postgres · rls- 1. Session → tenantrequireAuth
Every authenticated request resolves to one tenant id before any query runs.
- 2. Transaction-scoped contextwithTenantContext
Queries run inside a transaction that sets the tenant id for the connection's lifetime, as the app role — never the table owner.
- 3. Row-level security on 12 tablesFORCE ROW LEVEL SECURITY
RLS is enabled AND forced, with a tenant_isolation policy on each table. Another tenant's row is not a 403 — it does not exist to the query.
- 4. Boot gateassertTenantIsolation
The API refuses to start if any scoped table is missing RLS, is not forced, or has no policy. Isolation is checked, not assumed.
FAQ
How the engine behaves at the edges.
Can I edit the flows a bundle installs?
What does the engine do when the other system is rate-limited?
How does a split handle a very large batch?
Can I add a connector you do not have?
Is there an audit trail?
See one order flow end to end.
Thirty minutes with a Shopify store you control. We connect it, install a bundle, and walk the run record together.

