Skip to content

Calling functions

Call a function over HTTP, from the JavaScript client or the CLI; the answers, limits, rate limits and idempotency.

Calling a function

curl -X POST https://api.example.com/v1/shop/orders/_func/checkout \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"cart": "c1"}'
# → 200 {"order": "o7", "total": 1250}
  • The body is the function’s input (ctx.input), any JSON value; an empty body is null. The answer is its output. webhook functions are different: see Webhooks — the raw body reaches ctx.request.body unparsed, Content-Type isn’t required to be JSON, and the function’s own response is returned as-is.
  • In realms with auth: enabled, API keys may always call; other callers need the function’s invoke rule (webhook always needs one that allows anonymous callers). With X-Backd-On-Behalf-Of, an API key calls as that user.
  • With input.schema.json, invalid input answers 400 validation_error; with output.schema.json, an output that doesn’t match answers 500 invalid_output (and is logged). Neither applies to webhook.
  • An Idempotency-Key header reaches the function as ctx.idempotencyKey, and also deduplicates the call itself (see Idempotency).
  • sync functions run immediately and answer with their output, as above. async functions (below) answer 202 with a job to poll. webhook functions (below) set their own response.
AnswerWhen
200A sync function returned; the body is its output
202An async function was queued; poll Location for its result (see Async jobs)
(the function’s own choice)A webhook function’s own response (see Webhooks)
4xx with the function’s codeThe function threw ctx.error(status, code, message) (for async, this lands in the job’s result instead; webhook functions answer errors through their own response, not ctx.error)
400 validation_errorThe input doesn’t match input.schema.json
400 idempotency_key_requiredfunction.yaml has idempotency: required and the call has no Idempotency-Key
401, 403The invoke rule doesn’t allow the caller
404 not_foundNo such function
409 request_in_progressA call with this Idempotency-Key is still running (see Idempotency)
422 idempotency_key_reusedThis Idempotency-Key was already used with different input
429 too_many_requestsA sync or async function, or its realm, is at its concurrency limit on this instance (Retry-After: 1); or the caller is over the function’s rate limit (Retry-After set to when it resets)
500 function_failedThe function threw another error, crashed, or went over its memory or CPU limit. The stack trace is in backd’s log only
500 invalid_outputThe output is larger than max_output, or doesn’t match output.schema.json
500 secret_missingThe function declares a secret that isn’t set for its database or realm
503 unavailableThe executor isn’t configured, doesn’t answer, or runs its maximum of functions (Retry-After: 1); or a webhook function is at its concurrency limit (Retry-After: 1 — 503, not 429, since webhook senders retry on 5xx)
504 function_timeoutThe function didn’t finish within its timeout; it was stopped

From the JavaScript client and the command line

The same call, three ways:

# HTTP
curl -X POST https://api.example.com/v1/workshop/main/_func/order_total \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"order_id": "d3c9ljp8hc2g00b6s1m0"}'

# The command line: the same route and rules, with the session `backd login` stored (or BACKD_API_KEY)
backd functions invoke --function workshop/main/order_total --input order.json --url https://api.example.com
// The JavaScript client
const { total } = await backd.db('main').fn('order_total', { order_id: id })
  • db.fn(name, input, opts) returns the output of a sync function, and a Job handle for an async one (Async jobs). opts.idempotencyKey sets the Idempotency-Key header. See the client.
  • backd functions invoke needs --function <realm>/<database>/<name>, prints the output (and, when the server runs with BACKD_DEV=true, the function’s logs and timing), and --as <email> calls on a user’s behalf with an admin API key.
  • On behalf of a user. An API key may add X-Backd-On-Behalf-Of: <user id> to call as that user: their invoke rule and their access rules apply. See API keys.

Asking for a job: Prefer: respond-async

A sync function answers when it is done. A caller that would rather not wait (the call can be slow, or nobody needs the answer yet) asks for a job instead, with the standard Prefer header:

curl -i -X POST https://api.example.com/v1/shop/orders/_func/checkout \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -H 'Prefer: respond-async' -d '{"cart": "c1"}'
# → 202 Accepted
#   Preference-Applied: respond-async
#   Location: /v1/shop/orders/_jobs/d3c9ljp8hc2g00b6s1m0
#   {"id": "d3c9ljp8hc2g00b6s1m0", "function": "orders/checkout", "status": "queued", …}

Nothing changes for callers who don’t send it, so it is safe to adopt one call at a time, and nothing has to be declared in function.yaml.

  • The call is checked first, as any call: the invoke rule, a key’s scopes, the input schema, idempotency and the rate limit all answer before anything is queued.
  • The job is read and finished like an async function’s: poll Location (see Jobs), with a worker running. Its result is the function’s output or its own error, and the function keeps its own limits (its timeout and max_output as a sync function).
  • It runs once: retry is a setting of async functions, so a failure ends the job. Concurrency limits of the function don’t apply while it waits in the queue, only the worker’s.
  • With an Idempotency-Key, a repeated call replays whatever the first call was, the answer or the job, whatever the second one asks for.
  • Ignored where a job makes no sense: a webhook function (its sender waits for the function’s own response), a function called by another function with ctx.call (it needs the answer), and a realm without auth: enabled (there is nowhere to keep a job). The call is answered inline, without Preference-Applied.
  • An async function is always a job; sending the header only gets Preference-Applied back.

The JavaScript client sends it with fn(name, input, { respondAsync: true }) and resolves to a Job.

Concurrency limits

Two limits, both enforced by backd before it asks the executor to run anything:

  • concurrency in function.yaml (default 10, 1–1000): how many calls of that function may run at once.
  • functions.max_concurrency in realm.yaml: how many calls of any of the realm’s functions may run at once, together. Unset means unlimited (bounded only by each function’s own concurrency, and the executor’s own cap below).

Either limit answers 429 too_many_requests with Retry-After: 1 once it’s full for sync; a webhook function answers 503 instead (webhook senders retry on 5xx, not 4xx), and an async job waits in its queue rather than being refused.

These limits are per instance, not a limit for the whole deployment. Each backd process counts only the calls it is currently running. With N instances behind a load balancer, up to N × the value can run across the deployment at once — there is no cross-instance coordination, on purpose (that would need a shared counter for every call, adding latency and a dependency on every invocation). If you need a deployment-wide cap, size these per-instance limits accordingly (limit ÷ number of instances). If what you actually need is a cap per caller rather than total throughput, that’s rate limits (rate_limit in function.yaml, shared across instances), not this.

The executor also caps its own total running processes (EXECUTOR_MAX_PROCESSES), independent of any one function or realm — the backstop if concurrency/max_concurrency are set too high for the machine. Size it, and its container’s pids and memory limits, generously: each running function costs about 5 pids with --single-threaded (Deno’s own threads plus the process itself); a 2048-pids container failed under 100 concurrent calls at 20 threads per process during F0’s spike, which is why functions run --single-threaded.

Rate limits

rate_limit in function.yaml caps how often one caller may call a function, so a function anyone can call — one that sends email, say — can’t be abused as a relay by a single caller running it as many times as they like:

rate_limit:
  per: user      # user or ip
  limit: 10
  window: 1m     # 1s to 24h
{
  "error": {
    "code": "too_many_requests",
    "message": "this function's rate limit was reached; retry later",
    "request_id": "darf9mq5mh5g00aq9df0"
  }
}
  • per: user counts by the signed-in user’s id; a caller with no session (or an API key acting for no one in particular) falls back to per: ip, so the limit still applies before anyone signs in. per: ip always counts by client address.
  • Over the limit answers 429 too_many_requests with Retry-After set to when the window resets (unlike concurrency’s fixed Retry-After: 1, which doesn’t know when a slot will free up).
  • Shared across every backd instance — the opposite of concurrency limits, which are deliberately per instance. Counters live in the realm’s system database (<realm>___system, so rate_limit needs auth: enabled), reusing the same “N events per window” mechanism login throttling uses, under its own key namespace. Only functions that declare a rate_limit pay the extra write; a function without one is never throttled.
  • Startup warns for any function whose invoke rule allows anonymous callers and declares no rate_limit — it names the function, so you can decide whether that’s intentional.
  • This is a deliberate exception to “rate limiting is the reverse proxy’s job” (see the hardening checklist): only backd knows who is calling, since the proxy in front of it only sees IP addresses and can’t tell two users on the same NAT apart, or the same user across IPs. The proxy baseline (a per-IP limit in front of /v1/) still matters and is unaffected by this.

Idempotency

An Idempotency-Key header makes a retried call safe: a double-click, a client timeout that resends the request, or a proxy retry never runs a function twice for the same operation.

curl -X POST https://api.example.com/v1/shop/orders/_func/charge \
  -H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: order-42-charge" \
  -H 'Content-Type: application/json' -d '{"amount": 1250}'
# → 200 {"charged": 1250}   (the function actually ran)

curl -X POST https://api.example.com/v1/shop/orders/_func/charge \
  -H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: order-42-charge" \
  -H 'Content-Type: application/json' -d '{"amount": 1250}'
# → 200 {"charged": 1250}   (the same response, replayed — the function did not run again)
  • Same key, same input → the stored response, replayed without running the function again (for async, the same job id, not a new job). Same key, different input → 422 idempotency_key_reused: the key almost certainly meant a different operation, so backd refuses to guess which one you wanted. Same key while the first call is still running → 409 request_in_progress.
  • Scoped to the function and the caller (the signed-in user, or the API key acting for no one in particular): two different callers can never collide on the same client-chosen key, even by coincidence. A caller picks their own keys — a UUID per logical operation is the usual choice (e.g. one per order, generated once and reused across retries of that same order).
  • idempotency: required in function.yaml refuses calls with no key (400 idempotency_key_required); optional (the default) makes deduplication opt-in per call.
  • The key reaches the function as ctx.idempotencyKey, so it can be forwarded to a downstream idempotent API — Stripe’s own Idempotency-Key on the charge you make from inside the function, for example — for the same protection all the way through.
  • Responses are stored only for calls with a key — a deliberate second exception to “backd never stores a function’s input or output” (the first is async job results) — kept for 24 hours (fixed, not configurable). A key you never send leaves nothing behind.
  • A failure that isn’t really an outcome — the executor unreachable, busy, or a concurrency/rate limit hit — releases the key instead of remembering it, so a genuine retry can actually run the function; only a real result (the function ran to completion, however it ended: success, its own thrown error, a timeout, a crash) is replayed.
  • Deduplicating webhooks is a different problem Idempotency-Key doesn’t solve directly, since the sender chooses the request, not you (and most webhook senders don’t send an Idempotency-Key at all): use the event id the sender already includes (e.g. Stripe’s event.id) as a unique key in your own collection instead (a unique index refuses a duplicate insert). See Webhooks for a worked example.
Edit this page · Last updated 08 Oct 2026