Endpoints — user guide
Endpoints let you build your own APIs without writing server code. You define a URL, decide who may call it, and describe what it does as a list of steps — query records, check a condition, reshape data, call another service, send an email. When someone calls the URL, the steps run in order and the endpoint answers with a response you shaped.
Your endpoint lives at:
https://<your-workspace>/api/projects/<project-id>/fn/<slug>
The dashboard shows the exact URL on each endpoint's card, with a Copy URL button.
Quick start: your first endpoint
Goal: a public URL that returns your latest published articles, for your website to call.
- Open your project → Endpoints → + New endpoint.
- Name it
Latest articles, set method GET, sluglatest-articles, and leave "Who may call it" on anyone (rate limited). - In Steps, the canvas starts with one Query records step. Click it, pick your articles schema, set the filter line
status=live, limit10, and give it the output namearticles. - Set the Response body to:
{"articles": {{steps.articles}}, "count": {{steps.articles.length}} } - Click ▶ Test run to watch it execute, then Create endpoint.
Call it from anywhere:
curl https://<workspace>/api/projects/<project-id>/fn/latest-articles
{ "articles": [ { "id": "…", "title": "Launching v2", "status": "live" } ], "count": 1 }The three ideas everything builds on
1. A flow is an ordered list of steps. Steps run top to bottom, exactly as shown on the canvas. Each step either succeeds or fails; a failed step stops the flow (you can override this per step with continue on error).
2. Steps share a workspace called the context. Every step can leave its output in the context under its output name. Later steps — and the final response — read those outputs with {{…}} placeholders. Name your steps: {{steps.articles.0.title}} reads far better than {{steps.0.0.title}}.
3. Templates are simple substitution. Anything in double curly braces is replaced with a value from the context. There is no logic inside templates — logic belongs in condition and transform steps. An unknown placeholder is left visible in the output, which is your clue that a path is wrong.
What you can reference in templates
| Placeholder | Meaning |
|---|---|
{{body.something}} | A field from the caller's JSON request body (POST/PUT) |
{{query.something}} | A URL query parameter (?something=…) |
{{user.something}} | The logged-in end user's profile (auth-schema endpoints only) |
{{steps.<name>}} | A whole step output (arrays/objects embed as JSON) |
{{steps.<name>.0.title}} | A path into a step output — numbers index into arrays |
{{steps.<name>.length}} | How many items an array output holds |
{{trigger.something}} | Same as body.* on endpoints; the trigger payload on events |
Who may call it (auth modes)
| Mode | Caller | Sends | Rate limit | Use for |
|---|---|---|---|---|
| anyone (public) | Any visitor, no credentials | nothing | 60/min per IP | Site data, pages, feeds |
| API key | Your own backend or scripts | Authorization: Bearer esc_… | 300/min per key | Server-to-server, admin data |
| logged-in end user | A customer signed in via an auth schema | Authorization: Bearer <their token> | 120/min per user | "My orders"-style personal data |
- API keys are created under API keys and must be granted the
endpoints.invokepermission. A key is your site's identity — never ship one in browser JavaScript. - End-user tokens come from your auth schema's login API (
/api/projects/<id>/auth/<schema>/login). The endpoint verifies the token and exposes the caller's profile as{{user.*}}(user.id,user.email, and your schema's own fields — passwords and reset tokens are never included). Filter a query step bycustomerId={{user.id}}and each caller sees only their own records.
Calling from a browser (CORS)
Web pages on another domain can only call your endpoint if its Allowed browser origins field lists their origin (e.g. https://www.your-site.com), or * for any. Server-to-server calls ignore CORS entirely. If a fetch() from your site fails while curl works, this field is almost always the reason.
Step reference
Add steps with the picker under the canvas; click a step node to configure it. Every step has an optional output name, an enabled toggle, and continue on error.
🔍 Query records
Reads from one schema. Configure the schema, equality filters (one field={{value}} per line), a limit (max 100), an optional sort, and Expand relations — a list of relation fields to populate, so each record carries its related documents under expand: {{steps.posts.0.expand.author.name}}. Output: the matched records as an array.
🧮 Transform (reshape data)
Applies real logic with a JMESPath expression — filter, join, group, project. The expression reads the whole context (trigger, steps.<name>, body, query, user) and its result becomes the step's output. Example — live articles with author names, plus a count:
{live: steps.posts[?status=='live'].{title: title, by: expand.author.name},
total: length(steps.posts)}A response body of just {{steps.shaped}} serves a transform's result directly. Expressions are checked when you save; they cannot change data — only reshape it.
⑂ Condition (only continue if…)
A guard: if its checks fail, the flow stops cleanly and remaining steps are skipped. On an endpoint that answers the caller with 422 by default, or with the step's own stop status / stop response body if you set them — useful for input validation:
{{body.email}}exists, else stop with 422{"error":"email is required"}
Operators: equals, not equals, contains, greater/less than (numeric when both sides are numbers), exists, not exists. Combine checks with all or any.
➕ Create record / ✏️ Update record
Write to a schema. Field values are templates (email={{body.email}}), and every write passes the same validation as the records API — required fields, types, uniqueness, and relations that must point at real records. Update matches the first record of its filter; its output includes matched: true/false. Writes made by a flow do not trigger record events (no chain reactions).
🌐 HTTP request (use response)
Calls an external API and captures the reply as output: {status, ok, body} — JSON replies are parsed, so {{steps.api.body.result.id}} works. A non-2xx status is data, not a failure; check it with a condition step if it matters.
🔗 Webhook (fire and forget)
Sends an HTTP request without capturing the reply — notifications, pings. Custom headers and body template supported; the default body is the full trigger payload as JSON.
📧 Send email
Sends through the project's configured mail server, using a saved email template or an inline subject and body. All fields accept {{…}}.
Shaping the response
The Response section decides what the caller receives when every step succeeded:
- Status — default 200.
- Content type —
application/jsonby default. Choosetext/html,application/xml,text/plainortext/csvto serve the rendered template verbatim: whole pages, RSS feeds and sitemaps straight from a flow. - Body template — rendered against the final context. For JSON, if the rendered text parses it is served as JSON; if not, it is served as a plain string (a template typo never becomes a 500 — check the log).
- Extra headers — optional, templated (
set-cookieand friends are not allowed).
When things don't succeed, callers get honest but opaque answers:
| Situation | Caller receives |
|---|---|
| A condition stopped the flow | 422 {"error":"condition not met"} or your stop response |
| A step failed | 500 {"error":"endpoint execution failed","executionId":"…"} |
| Unknown slug, wrong method, or disabled | 404 |
| Missing/invalid credentials | 401 (or 403 for a key without endpoints.invoke) |
Internals never leak to callers — the details live in the execution log, findable by that executionId.
Caching
Public GET endpoints can cache responses for up to an hour (Cache responses for in the editor). Repeated calls with the same query string are served instantly without touching your database; responses carry cache-control and an x-cache: hit|miss header so you can see it working. Editing or disabling the endpoint clears its cache immediately; otherwise entries simply expire. Caching is deliberately unavailable on API-key and end-user endpoints — nothing personal is ever cached.
Testing and debugging
▶ Test run (in the editor) executes your unsaved draft against a sample request body and shows every step's result, its output, and the rendered response — before you save. Note that it runs steps for real: records are written and emails sent.
Log (on each endpoint card) shows recent invocations: when, outcome, each step's result and a preview of its output. A failed automation reads as "stopped here, and this is what it had computed".
Common symptoms:
| Symptom | Cause |
|---|---|
{{steps.foo.bar}} appears literally in the response | The path is wrong, the step is unnamed, or it output nothing — check the log's output previews |
| Works in curl, fails in the browser | Add your site's origin to Allowed browser origins |
| 401 with a valid-looking end-user token | Token is for a different auth schema/project, or expired (7 days) |
| 403 with an API key | The key lacks the endpoints.invoke permission |
| 404 on a URL that exists | Wrong method (slugs are per-method), or the endpoint is disabled |
| Stale data | You're inside the cache window — edit-and-save clears it |
Organizing and limits
- Groups: give endpoints a group name and the list arranges itself into sections (e.g. Site data, Account, Admin). The field suggests names you've already used.
- Slugs are lowercase letters, digits and dashes, unique per method — the same slug can serve GET and POST as two endpoints.
- Permissions: managing endpoints requires the
endpoints.read/create/ update/deletepermissions; invoking with a key requiresendpoints.invoke.
Guardrails (fixed): 20 steps per flow · 30s total run budget · query limit 100 records · request bodies 100 KB · captured HTTP responses 256 KB · outbound calls are egress-checked, never follow redirects, and time out at 10s.
Three worked examples
A composed read (two schemas + logic) — GET /fn/articles-with-authors
- Query records
posts: articles, limit 50, expandauthor - Transform
shaped:{live: steps.posts[?status=='live'].{title: title, author: expand.author.name}, drafts: steps.posts[?status=='draft'].title, total: length(steps.posts)} - Response body:
{{steps.shaped}}
Validated intake — POST /fn/articles-by-status
- Condition:
{{body.status}}exists, else stop 422 with a helpful error - Query records
matched: articles wherestatus={{body.status}} - Response:
{"status": "{{body.status}}", "count": {{steps.matched.length}}, "articles": {{steps.matched}} }
A personal feed (end-user auth) — GET /fn/my-feed, auth mode logged-in end user on the customers schema
- Query records
live: articles wherestatus=live, expandauthor - Transform
shaped:{welcome: user.firstName, articles: steps.live[].{title: title, by: expand.author.name}} - Response body:
{{steps.shaped}}— every caller gets a response addressed to them, and a query filtered by{{user.id}}would return only their own records.
*Events use the same steps and templates — an event is a flow triggered by a form submission, a record change, or a manual run, instead of an HTTP call. Everything in the step and template references above applies there too. Related guides: Schemas & records, Auth schemas, API keys.*