Custom Endpoints and Dedupe
FRL endpoints let a theme expose small JSON routes for workflows that do not fit a normal createPost, updatePost, or deletePost call.
Use them for checks, calculators, action buttons, callback-style workflows, and controlled operations where the rule file should decide what is accepted and what response is returned.
Basic Endpoint
endpoint.post createLead("/leads/submit") as "Create Lead" {
must($request.body.email && $request.body.acceptedTerms, "Email and terms are required", 422);
dedupe `${str.lower(str.trim($request.body.email))}:${$request.body.campaign || "default"}`;
$context.email = str.lower(str.trim($request.body.email));
return {
ok: true,
email: $context.email,
message: "Lead received"
};
}
Call it from the theme by its endpoint name:
const result = await Frontbacked.endpoints.call("createLead", {
allowGuest: true,
body: {
email: "Ada@Example.com",
campaign: "summer",
acceptedTerms: true
}
});
The browser receives the object returned by the endpoint body. If the returned value is not an object, Frontbacked wraps it as { ok: true, result }.
Endpoint calls require a signed-in visitor by default. Set allowGuest: true when the endpoint should accept guests. If the visitor is signed in, Frontbacked includes their authentication in either case. Direct HTTP clients and webhook senders can still call the endpoint path at /fb-endpoints/leads/submit.
Endpoint Syntax
endpoint.method endpointName("/path/:param") as "Friendly endpoint label" {
must(expression, "Optional message", 422);
mustNot(expression, "Optional message", 409);
dedupe expression;
return { ok: true };
}
| Part | Purpose |
|---|---|
method | One of get, post, put, patch, or delete. |
endpointName | A unique name for this endpoint in the rule file. |
"/path/:param" | Public path under /fb-endpoints. Params can use :id or {id}. |
as "Friendly endpoint label" | Optional UI label exposed as endpoint.<name> metadata. It does not change the endpoint name, method, path, or how the route is called. |
must(expression, message?, status?) | Assertion for required values. A falsy value stops the endpoint. |
mustNot(expression, message?, status?) | Optional inverse assertion. A truthy value stops the endpoint. |
dedupe | Optional stable key that makes repeated matching requests safe. |
| Endpoint statements | Assign $context, call helpers, and return a JSON response directly inside the endpoint block. |
FRL uses allow for post action/list permissions and must(...) or mustNot(...) for endpoint and hook assertions. A failed assertion stops the rule run; when a second argument is provided, it becomes the error message. A third argument can set the HTTP status returned to the caller and must resolve to an integer from 400 to 599.
Use aliases when admin tools need a clearer label than the endpoint name. Comments remain normal comments; they are not used for endpoint labels in FRL v2.
endpoint.post newsletterSignup("/newsletter") as "Newsletter Signup" {
must($request.body.email, "Email is required", 422);
return { ok: true };
}
Request Data
Endpoint rules read request data through $request.
| Value | Example |
|---|---|
| Method | $request.method |
| Matched path | $request.path |
| Declared endpoint path | $request.endpointPath |
| Path params | $request.params.slug |
| Query params | $request.query.plan |
| Parsed body | $request.body.email |
| Raw body text | $request.rawBody |
| Headers | $request.headers["content-type"] |
Endpoint $request is intentionally narrow. It does not include the full URL, origin, hostname, site domain, cookies, authorization tokens, or forwarding/IP headers.
Other familiar FRL values are also available: $actor, $admin, $secret, $private, $page, $currency, $context, and local fn helpers.
Path Params
endpoint.get checkUsername("/username/:username") {
must($request.params.username, "Username is required");
$context.username = str.lower(str.trim($request.params.username));
return {
ok: true,
username: $context.username
};
}
Theme code passes path parameters by name:
const result = await Frontbacked.endpoints.call("checkUsername", {
allowGuest: true,
params: { username: "Ada" }
});
The endpoint receives "Ada" as $request.params.username. A direct HTTP caller can make the equivalent request to /fb-endpoints/username/Ada.
GET, PATCH, and DELETE Calls
Each named endpoint owns its HTTP method. Theme code calls all of them through the same helper and never repeats the method or public route.
endpoint.get getProductSummary("/products/:id/summary") {
$context.product = posts.findOne({
type: "products",
where: { id: $request.params.id }
});
must($context.product, "Product not found", 404);
return { ok: true, product: $context.product };
}
endpoint.patch renameProduct("/products/:id/name") {
must($admin.canEdit("products.name"), "You cannot rename products", 403);
must($request.body.name, "Name is required", 422);
post.edit($request.params.id, { name: str.trim($request.body.name) });
return { ok: true, id: $request.params.id };
}
endpoint.delete removeProduct("/products/:id") {
must($admin.canDelete("products"), "You cannot delete products", 403);
post.delete($request.params.id);
return { ok: true, id: $request.params.id };
}
const summary = await Frontbacked.endpoints.call("getProductSummary", {
allowGuest: true,
params: { id: productId }
});
const renamed = await Frontbacked.endpoints.call("renameProduct", {
params: { id: productId },
body: { name: "Silk Wave 20 inch" }
});
const removed = await Frontbacked.endpoints.call("removeProduct", {
params: { id: productId }
});
The GET call opts into guest access. PATCH and DELETE keep the helper's authenticated default, and Frontbacked attaches any available authentication to every call.
Canceling A Call
Use an AbortSignal when a request becomes obsolete, such as when someone types a new search before the previous search finishes:
let searchController;
async function searchProducts(search) {
searchController?.abort();
searchController = new AbortController();
const result = await Frontbacked.endpoints.call("searchProducts", {
allowGuest: true,
query: { search },
signal: searchController.signal
});
if (result.aborted) return;
return result;
}
A canceled call returns { ok: false, aborted: true }. Cancellation stops the browser from waiting for the result, but work that has already started may still finish.
Dedupe
Use dedupe when a request may be repeated by the browser, a user double-click, a network retry, or an external callback.
endpoint.post confirmOrder("/orders/confirm") {
must($request.body.orderId && $request.body.reference, "Order and reference are required");
dedupe `${$request.body.orderId}:${$request.body.reference}`;
return {
ok: true,
orderId: $request.body.orderId
};
}
With a dedupe key:
| Situation | Result |
|---|---|
| First request for a key | The endpoint runs normally. |
| Same key, same request, already completed | Frontbacked returns the saved response. |
| Same key, same request, still running | HTTP 202 with { "ok": true, "status": "processing" }. |
| Same key after a server failure (5xx) | The request can retry. |
| Same key after a validation failure (4xx, except wallet confirmation and rate limiting) | Frontbacked returns the saved failure. |
| Same key, different body, query, route parameters, method, or path | HTTP 409 Conflict; the action does not run again. |
Keys are scoped to the site and endpoint. Frontbacked uses the developer’s key without adding a user identity. Include $actor.id when the action belongs to a user; for webhooks, use the sender’s stable event ID. Place authorization checks before dedupe to check them on every request. A saved response stops execution at the dedupe statement; later checks and actions do not run.
Frontbacked does not automatically rerun failed endpoints. The caller decides whether and when to retry, using the same key and request. A server failure (5xx) leaves the attempt retryable instead of permanently replaying that failure. HTTP 202 means the attempt is still processing; wait before checking again. Do not retry every non-200 response: validation failures need correction, and a key conflict needs a different key for a genuinely different action.
Completed responses remain replayable: a completed key never starts a new purchase, even after its payment expires. Generate a new attempt ID for a new purchase or to replace an expired payment. Generate the ID once in the browser and keep it across retries and reloads; do not generate it inside the endpoint.
A payment created inside a deduplicated endpoint is recovered when the same attempt retries after an interruption. Reusing that payment attempt with different payment details is rejected. Keep payment calls and their ordering stable during retries. Built-in endpoint writes and the saved successful response commit together. Calls to external services still need the external service’s own idempotency support.
Saved responses and payment expiry
Dedupe saves the endpoint response, whether it contains a payment, a newly created record, or a custom result. A completed matching request returns that saved response without rerunning the action or refreshing the data inside it. Fetch the resource separately when you need its current state.
For example, an endpoint returning payment.create(...) first returns payment A with its original payment details. If payment A later succeeds or expires, the same key and matching request still replay that original endpoint response, including payment A’s ID. The response may still describe the payment as pending because it is a snapshot. The payment widget fetches the current payment state by ID.
Payment expiry does not expire or reset the dedupe key. Reusing a completed key cannot extend the payment’s expiry, generate fresh payment instructions, or create payment B. To replace an expired payment, start a new checkout attempt with a new key. If the original endpoint was interrupted before its response was saved, recovery still refers to payment A rather than creating a replacement under the same key.
Headers are excluded from the request comparison, allowing retries to carry fresh authorization tokens, webhook signatures, or delivery timestamps. Checks before dedupe still validate each request. If a header identifies a different action, include that value explicitly in the dedupe key.
For a conflict, Frontbacked returns:
{
"ok": false,
"error": "Idempotency key was already used with a different request."
}
Send the original request to retry the same action. Use a new key only when intentionally starting a different action; automatically changing keys after errors defeats duplicate protection.
Good keys are deterministic and scoped to the real-world action:
dedupe `${$actor.id}:${$request.body.invoiceId}:${$request.body.reference}`
dedupe `${str.lower(str.trim($request.body.email))}:${$request.body.campaign}`
Avoid random keys for repeat-safe endpoints:
// Do not do this for dedupe.
dedupe str.id(16)
A random key changes on every retry, so Frontbacked cannot tell that two requests are the same action.
Signed Webhooks
For third-party callbacks, verify the signature before writing anything. Use $request.rawBody for the signed payload unless the provider documents a different signed string.
endpoint.post providerWebhook("/provider/webhook") {
must(crypto.verifyHmac({
algorithm: "sha256",
secret: $secret.WEBHOOK_SECRET,
payload: $request.rawBody,
signature: $request.headers["x-provider-signature"],
prefix: "sha256="
}), "Invalid signature", 401);
dedupe `provider:${$request.body.id}`;
post.createAsSystem("webhook_events", {
externalId: $request.body.id,
payloadHash: crypto.sha256($request.rawBody),
receivedAt: time.now()
});
return { ok: true };
}
Endpoint Writes
Endpoint bodies can use controlled write helpers when the endpoint should create, edit, or delete related posts.
endpoint.post leadWebhook("/lead-webhook") {
must($request.body.email, "Email is required");
dedupe `lead:${str.lower(str.trim($request.body.email))}:${$request.body.reference}`;
$context.email = str.lower(str.trim($request.body.email));
post.createAsSystem("leads", {
email: $context.email,
leadId: "$id",
externalReference: $request.body.reference,
source: $request.body.source || "endpoint",
createdAt: time.now()
});
return {
ok: true,
email: $context.email
};
}
Pair endpoint-created records with dedupe when repeats would be harmful. Frontbacked generates the related post id, and the exact string "$id" can be used inside the related post data when that generated id should be copied into a normal field.
Use post.createAsSystem(...) for automatic endpoint work such as webhook imports, logs, and background records. Use post.createAsActor(...) only when the endpoint requires a signed-in visitor/admin and the created record should belong to that person.
Practical Patterns
| Pattern | Why endpoints help |
|---|---|
| Availability check | Return { ok, available } without storing a new post. |
| Quote calculator | Compute from $private, $currency, request body, and current user state. |
| Action button | Let one click perform a guarded server-side operation. |
| Callback-style workflow | Accept a structured payload, check it, and update related data. |
| Paid action | Use chargeUser(...) inside a guarded endpoint or hook when the action should require wallet confirmation. |
Keep endpoints small and explicit. If the result is a normal user-created record, prefer createPost. If the action needs a custom request shape, a custom response, dedupe, or controlled side effects, use an FRL endpoint.
Retrying after a reservation conflict
A failed checkout with code: "reservation_unavailable" and HTTP 409 is saved under its dedupe key. Reusing that key replays the failure even if capacity later becomes available. After showing the stock message, start the customer's next deliberate checkout attempt with a new key. Do not automatically retry in a loop.
A request fingerprint conflict instead returns code: "dedupe_conflict". Resolve the conflicting request before replacing its key. Keep the same key after network failures or uncertain outcomes so an existing payment is not duplicated. The wig theme demonstrates these distinct paths.
Rate-limit responses (429, code: "rate_limited") are retryable with the same key and unchanged request details after Retry-After. A rateLimit before dedupe also checks replay requests; one after dedupe runs only if execution continues. See Rate Limits and Abuse Protection.
Statement order
FRL executes endpoint statements in their written order, awaiting each action before continuing. Definitions such as schemas and named rate-limit policies remain declarations.
endpoint.post checkout("/checkout") {
rateLimit checkout_requests;
must($actor.id, "Sign in to continue.", 401);
dedupe `${$actor.id}:${$request.body.attemptId}`;
rateLimit checkout_attempts;
// Checkout actions follow.
return { ok: true };
}
Declare both rate-limit policies separately. The first policy checks every request reaching it. Authentication runs next. Dedupe may return immediately; only a request that continues reaches the second policy and checkout actions. A return or failed assertion stops all later statements. Moving a check across the dedupe line changes whether it runs on replay.
Named declarations follow the FRL naming rules. The system, system_…, and system.… names are reserved for Frontbacked, case-insensitively. Ordinary data fields are unaffected.