mooflair exposes a few HTTP routes. Most are for nodes during a run (the run API, /api/run/*); the rest start flows from outside or stream the agent. The public REST API for your own systems (/api/v1) and outgoing webhooks are in REST API and webhooks.
| Method | Path | Auth | Used by |
|---|---|---|---|
| POST | /api/run/table | run token | Data table node |
| POST | /api/run/limit | run token | A node's limit setting (the engine calls it) |
| POST | /api/run/account | run token | Sign in reports who its browser is signed in as |
| POST | /api/run/unique | run token | A node's no repeats setting (the engine calls it) |
| POST | /api/run/cache | run token | Run flow (cache): operation: "get" reads it, "set" keeps its output |
| POST | /api/run/notify | run token | Notify node, interventions |
| GET / POST | /api/hook/<token> | the token | Webhook trigger |
| POST | /api/form/<token> | the token | Form trigger page |
| GET · POST | /api/internal/* | engine secret | The engine's internal API: apps' config, what a run needs, a browser's first use, events, usage (see Engine API) |
| POST | /api/studio/agent | session | Studio agent |
The run token
Every run gets api: { link, token } from mooflair when it starts. token is a JWT signed with mooflair's secret.jwt (configuration.json): { sub: workspaceId, scope: "run", run, journal, flow, timezone, campaign: { id, group } | null, dry }, valid 7 days (so endless campaign runs keep it). campaign picks the group rows a run sees; dry makes a test run's rows test rows. Nodes send it as Authorization: Bearer <token>; it only reaches that user's data. link is configuration flow.api.url (empty: site.url): in development http://host.docker.internal:<port>, so browser containers reach your dev server.
Tables
POST /api/run/table
{
"table": "6ae1…",
"account": null,
"operation": "find",
"where": { "key": ["123", "456"], "label": "replied" },
"data": { "key": "123", "label": "replied", "url": "https://x.com/…" },
"holdMs": 900000,
"limit": 1,
"sort": { "column": "repliedAt", "direction": "desc" }
}
| Field | Meaning |
|---|---|
table | The id of a table of yours, or a node's own key such as x/collect/post |
account | The account id a per-account table keeps rows for |
operation | find · insert · update · upsert · delete · claim (insert keys not taken in the scope; the rows that come back are yours) |
where | Columns, or id, key, label (a value or a list) |
data | Columns plus key, label (and campaignId for a row about another campaign of yours) |
holdMs | Rows expire after this (else the table's row lifetime) |
The scope comes from the token: the table's scope for this run's campaign group or account, as test rows in a test run. Answers { "rows": [...], "count": n } (each row { id, key, label, ...columns, createdAt }); 404 with { "error" } when the table isn't found.
Account sign-in
POST /api/run/account
{ "accountId": "6ae45a8f908b88c2b9f267bf", "platform": "X", "userId": "1849…", "username": "grandvoucher_" }
A Sign in node reports who its browser is signed in as (username is null for Shopee). The account's first sign-in binds userId when username is its username, or when it has none yet (added by signing in: the handle becomes it); after that the user id decides (a renamed handle updates the username).
Answers { "isSame": true, "username": "grandvoucher_" }, { "isSame": false, "username": "<who it should be>" }, or 409 when another account of the workspace is already bound to that user.
Limits
POST /api/run/limit
{ "key": "x/post:6ae45ab0908b88c2b9f267c1", "window": [{ "max": 1, "everyMs": 10800000 }, { "max": 6, "everyMs": 86400000 }], "operation": "add" }
The engine calls it for a node's setting and an account's limit: at most max in any everyMs (60 000 to 30 days) for every window at once (up to 5), rolling windows over one list of times per key. max 0 counts nothing; no window: no limit. It answers { "count", "isAllowed", "nextAt", "window" }: nextAt is when every window has room again, window the one that frees last.
check, before the node runs:{ "count": 1, "isAllowed": false, "nextAt": "2026-10-30T15:00:00.000Z" }(nextAt: when the next may go,nullwhile there's room).add, once it ran: counts one now only while undermax(atomic). Test runs never add.
Each key keeps when it counted (newest first) per workspace, and is gone once its newest one leaves the window. A rollback takes its counts back. The key is the node's limit.key, else <node type>:<account id>.
No repeats
POST /api/run/unique
{ "table": "x/post/post", "column": "text", "accountId": "6ae45ab0908b88c2b9f267c1", "text": "Kopi susu gula aren lagi viral…", "similarity": 0.8, "aiId": "6ae4…" }
The engine asks it before a node with a setting runs. It reads the table's last 1000 rows (as the table keeps them: per accountId, or per the run's campaign group; test runs read test rows) and answers { "isUnique": true, "same": null }, or { "isUnique": false, "same": "<the one it matched>" } when the words match at similarity or, with aiId (an AI key id), when the AI says it repeats one of the last 30 in other words. It records nothing.
Called-flow cache
POST /api/run/cache
{ "operation": "set", "flowId": "6ae08f2f…", "input": { "keyword": "tumbler" }, "minutes": 360, "output": { "link": "https://s.shopee.co.id/…" } }
operation: "get" answers { isHit, output }; "set" keeps output for minutes.
The key is sha256("<flowId>:<JSON input>:<campaign id>") per user: each campaign keeps its own (its options pick the accounts, its id is the Shopee Sub ID). Read answers { "isHit": true, "output": … }; write answers { "isSaved": true }. minutes: 1 to 7 days.
Notifications
POST /api/run/notify
{ "title": "𝕏 changed its page", "message": "Missing: replyButton", "level": "error", "runId": "6ae1…" }
level: info · success · warning · error. Shown under the bell in the app header. A run that starts waiting for you posts one too (<flow> is waiting for you, level warning). Discord and Slack notifications go straight from the Notify node to the webhook URL you set (only https://discord.com|discordapp.com/… and https://hooks.slack.com/… are accepted). Test runs only send to the bell.
Respond
POST /api/run/respond
{ "status": 201, "header": { "x-id": "42" }, "body": { "ok": true } }
A node's answer to the webhook call that started its run. The first one counts; a second answers 409. The webhook route, waiting for it, sends it (kept 10 minutes as FlowRespond, by the run's journal id).
Knowledge
POST /api/run/knowledge
{ "base": "6ae3…", "operation": "search", "query": "how long does shipping take", "topK": 4, "minScore": 0, "filter": {} }
One of the workspace's . search answers { match: [{ text, score, documentId, metadata }] }. insert takes text, or file: { name, mime, data } (base64, 10 MB), with name, metadata, documentId (replace it) and isClear (empty the base first), and answers 201 { document }. delete takes documentId, or a metadata filter, and answers { count }. 404 for a base of another workspace, 422 when the text or the embedding fails.
Next post
POST /api/run/post
{ "source": { "kind": "set", "setId": "6ae3…" }, "order": { "by": "shuffle", "direction": "asc" }, "repeat": "unique", "count": 1 }
's pick for the run's campaign (the token's campaign; Studio runs: none; test runs counted apart). source is a (kind: "set") or a Drive folder (kind: "folder", "path": "/promo", count files). order.by: shuffle, name or date; repeat: unique (each once, then item: null) or rotate (the least used next). Answers { item: { key, name, text, media: [{ fileId, name, mime, size }] } | null, left, total }; left is what unique still has. A set's post whose media is gone from the Drive is marked and skipped. When the last one of unique goes out on a campaign, the workspace gets a notification. 404 for a set of another workspace or a folder that isn't there.
Chat
POST /api/run/chat
{ "text": "Hi, what's your name?", "isWait": true }
A node's message to the chat its run answers (found by the run's journal id). isWait: true makes the chat's next message resume this run instead of starting a new one. 404 when the run didn't come from a chat.
Store
POST /api/run/store
{ "operation": "increment", "scope": "workspace", "key": "orders", "by": 1 }
The node's values: get, set, increment, append, remove, delete answer { key, value }; list answers { keys } (with prefix). scope flow keeps them per flow (the token names the flow), campaign per campaign group, workspace for everyone. Test runs have their own values. Writes are journaled for rollbacks.
Text from a file
POST /api/run/text { name, mime, data } (base64, 10 MB) answers { text }: PDF, DOCX and HTML read by mooflair, for .
Agent memory
POST /api/run/memory
{ "key": "support-chat-42", "operation": "get" }
An 's conversation under its memory key, per workspace (a test run's are test:<key>). get answers { message: [{ role, content }] }; set with message replaces them. Kept 30 days after the last use.
Resume URL
{{ run.resume }} is /api/resume/<token> on the site URL. Its token names the run and lasts 30 days. Any method works:
| Call | What the waiting node gets |
|---|---|
POST {{ run.resume }} with a body | { method, query, header, body } (cookies and authorization left out) |
GET {{ run.resume }}?answer=approve (a link in a mail) | the same, plus answer: "approve"; a browser gets a "Thanks" page |
POST { "answer": { … } } (the wait form) | answer |
It answers 200 { "ok": true }, 404 when the run is over, 409 when it isn't waiting now (already answered), and 429 past 30 calls a minute. mooflair relays it to the engine's POST /flow/run/resume. The wait form page is /form/wait/<token>.
Credential token
POST /api/run/credential/token
{ "id": "6ae1…", "isForce": true }
A node mid-run gets one of its workspace's resolved again: { id, type, value, auth: { header, query }, oauth }. An OAuth2 token with a minute or less left is refreshed; isForce: true refreshes it now (the service answered 401 to the old one). HTTP request does this once on a 401. Answers 404 for a credential of another workspace, 409 when it can't refresh (not connected, refresh refused).
Webhook and Form
See : /api/hook/<token> answers 202 { "runId" }; /api/form/<token> takes { "answer": { "<question name>": "…" } } from the public form page.
Agent stream
POST /api/studio/agent
One agent turn, streamed as NDJSON (one event per line). Closing the request stops streaming; the turn finishes and saves. Session cookie auth. See .
FAQ
Can I call the run API from outside a run?
No. It needs a run token, which mooflair gives only to a run when it starts.