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 URL that returns your latest published articles, for your website to call.

  1. Open your project → Endpoints → + New endpoint.
  2. Name it Latest articles, set method GET, slug latest-articles, and leave "Who may call it" on API key with endpoints.invoke.
  3. In Steps, the canvas starts with one Query records step. Click it, pick your articles schema, set the filter line status=live, limit 10, and give it the output name articles.
  4. Set the Response body to:
    {"articles": {{steps.articles}}, "count": {{steps.articles.length}} }
  5. Click ▶ Test run to watch it execute, then Create endpoint.

Under API keys, create a key holding endpoints.invoke (or reuse your site's key), then call it with the key in the ESC_API_KEY header:

curl https://<workspace>/api/projects/<project-id>/fn/latest-articles \
  -H "ESC_API_KEY: esc_…"
{ "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

PlaceholderMeaning
{{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)

Every endpoint needs a project API key holding endpoints.invoke, sent in the ESC_API_KEY header. The mode decides whether that is enough:

ModeCallerSendsRate limitUse for
API key with endpoints.invokeYour site, backend or scriptsESC_API_KEY: esc_…300/min per keySite data, pages, feeds, server-to-server
API key + logged-in end userA customer signed in via an auth schema, through your siteESC_API_KEY: esc_… and Authorization: Bearer <their token>120/min per user"My orders"-style personal data

On top of those, every endpoint URL allows 60 calls a minute per IP.

  • API keys are created under API keys and must be granted the endpoints.invoke permission. A key is your site's identity. Keep it on your server where you can; if pages call an endpoint from the browser, the key they carry is visible to visitors, so give that key nothing beyond endpoints.invoke (and forms.submit / auth.use if your site needs them).
  • Signed in to the dashboard, you can open an API-key endpoint in your browser without a key — your session and role stand in for it.
  • **Authorization: Bearer esc_…** still works for the key in API-key mode, but is deprecated (responses carry a Deprecation header). In end-user mode the Bearer slot is the user's token, so the key must be in ESC_API_KEY.
  • 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 by customerId={{user.id}} and each caller sees only their own records.
There is no anonymous mode any more. Until 4.0 endpoints could be anyone (public). That mode can no longer be chosen, and endpoints saved with it now behave exactly like API key endpoints — callers that sent nothing get 401 until they send a key.

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. The list is enforced on the server: a browser on an origin it leaves out gets 403 ORIGIN_NOT_ALLOWED (layer: endpoint) and the flow never runs. It is one of the allowed-origins layers — when the project or the calling key has a list too, the origin must be on all of them. Preflight requests allow the ESC_API_KEY and Authorization headers. Server-to-server calls send no Origin and are never checked. If a fetch() from your site fails while curl works, these lists are almost always the reason. Responses expose the Deprecation and Warning headers to browser code; an access-control-expose-headers you set under Extra headers is kept and merged with them. access-control-allow-origin and access-control-allow-credentials cannot be set there — this field replaces them, and credentials are never allowed.

const res = await fetch(`${API}/fn/latest-articles`, {
  headers: { ESC_API_KEY: SITE_KEY },
});

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/json by default. Choose text/html, application/xml, text/plain or text/csv to 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-cookie and friends are not allowed).

When things don't succeed, callers get honest but opaque answers:

SituationCaller receives
A condition stopped the flow422 {"error":"condition not met"} or your stop response
A step failed500 {"error":"endpoint execution failed","executionId":"…"}
No API key401 API_KEY_REQUIRED — for every slug, real or not, so nothing is revealed without a key
Unknown slug, wrong method, or disabled (with a valid key)404
Invalid key, or a missing/invalid end-user token401
A key without endpoints.invoke403

Internals never leak to callers — the details live in the execution log, findable by that executionId.


Caching

GET endpoints in API-key mode 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; the key is still checked on every call. Responses carry cache-control: private (so shared proxies never hand them to someone without a key) 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 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:

SymptomCause
{{steps.foo.bar}} appears literally in the responseThe path is wrong, the step is unnamed, or it output nothing — check the log's output previews
Works in curl, fails in the browserAdd your site's origin to Allowed browser origins (and to the project's and key's lists, if they are set)
403 ORIGIN_NOT_ALLOWEDThe browser's origin is missing from the list named by layer — project, api_key or endpoint
A browser sees a CORS error instead of a 401 for a missing or wrong keyKey refusals carry CORS headers only for origins on the project's list; check the key, or add the origin to the project
401 API_KEY_REQUIREDNo key sent — or a proxy in front of you dropped the ESC_API_KEY header (nginx does unless underscores_in_headers on)
401 on an endpoint that used to work with no credentialsIt was saved as anyone (public) before 4.0; send a key with endpoints.invoke
401 with a valid-looking end-user tokenToken is for a different auth schema/project, or expired (7 days) — or the ESC_API_KEY header is missing
403 with an API keyThe key lacks the endpoints.invoke permission
404 on a URL that existsWrong method (slugs are per-method), or the endpoint is disabled
Stale dataYou'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/delete permissions; invoking requires a key with endpoints.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

  1. Query records posts: articles, limit 50, expand author
  2. Transform shaped: {live: steps.posts[?status=='live'].{title: title, author: expand.author.name}, drafts: steps.posts[?status=='draft'].title, total: length(steps.posts)}
  3. Response body: {{steps.shaped}}

Validated intake — POST /fn/articles-by-status

  1. Condition: {{body.status}} exists, else stop 422 with a helpful error
  2. Query records matched: articles where status={{body.status}}
  3. Response: {"status": "{{body.status}}", "count": {{steps.matched.length}}, "articles": {{steps.matched}} }

A personal feed (end-user auth) — GET /fn/my-feed, auth mode API key + logged-in end user on the customers schema, called with the site's key and the customer's token

  1. Query records live: articles where status=live, expand author
  2. Transform shaped: {welcome: user.firstName, articles: steps.live[].{title: title, by: expand.author.name}}
  3. 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.*