Node
Call Formerie from any Node server with fetch, which is built in, with nothing to install. This page is for plain Node, Express, Fastify and Hono. Using a framework with its own page? Start from its quickstart. You don’t need a Formerie package or an API key: a form’s public address is all it takes.
Submit a form
Save this as submit.mjs, and run node submit.mjs.
const RUNTIME_URL = process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com";
// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";
const response = await fetch(`${RUNTIME_URL}/${FORM}/submit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fields: {
name: "Jane Example",
email: "jane@example.com",
message: "Hello from my server",
},
}),
signal: AbortSignal.timeout(10_000),
});
const result = await response.json();
console.log(response.status, result.ok ? result.submissionUid : result.error.code);Replace acme/contact, on the marked line, with your own handles. A success is HTTP 202, with a submissionUid such as sub_p6dr8wq2j7tkan: the answers are stored, and their email is queued. Each sample reads two optional settings from the environment, FORMERIE_RUNTIME_URL for a hostname you’ve connected, and FORMERIE_FORM for the handles.
Before you start
- Publish a form, and take its workspace and form handles from the Publish tab. Your server doesn’t need to be in the form’s allowed websites.
- To try things without emailing anyone, create a test key on the form’s Tests tab, and put it in an environment variable called
FORMERIE_TEST_KEY:
const RUNTIME_URL = process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com";
// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";
const response = await fetch(`${RUNTIME_URL}/${FORM}/submit`, {
method: "POST",
headers: {
"Content-Type": "application/json",
// Keep the key out of your code. A test is stored apart and emails nobody.
"X-Formerie-Test-Key": process.env.FORMERIE_TEST_KEY,
},
body: JSON.stringify({
fields: { name: "Jane Example", email: "jane@example.com", message: "Just a test" },
}),
signal: AbortSignal.timeout(10_000),
});
const result = await response.json();
console.log(response.status, result.ok ? result.submissionUid : result.error.code);A test comes back as tst_..., is stored apart, and emails nobody. A wrong key gets TEST_KEY_INVALID, HTTP 401, and nothing is stored. Remove the header before you go live, since a key left in place files everything as a test. See Test from a server or CI.
Check answers without submitting
validate checks answers and stores nothing, so you can flag a problem early. It answers 200 when they pass, and 422 with the same errors as a submission when they don’t. The final submission always checks the whole form again.
const RUNTIME_URL = process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com";
// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";
// Checks answers and stores nothing. With no step, only the fields you send are checked.
async function checkAnswers(fields, step) {
const response = await fetch(`${RUNTIME_URL}/${FORM}/validate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(step ? { mode: "step", step, fields } : { mode: "partial", fields }),
signal: AbortSignal.timeout(10_000),
});
const result = await response.json();
if (!result.ok && result.error.code !== "VALIDATION_FAILED") {
throw new Error(result.error.code);
}
return result;
}By default, only the fields you send are checked. Pass a step to check every field in that step instead, on a form with steps. A step the form doesn’t have gets BAD_REQUEST.
Show errors per field
Answers that fail come back as VALIDATION_FAILED, HTTP 422, and nothing is stored. error.fields holds the problems by field, and error.issues holds the same ones in order. Each has a path, a code to match on, and a message that’s safe to show, in the form’s language.
const RUNTIME_URL = process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com";
// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";
async function submitAnswers(fields) {
const response = await fetch(`${RUNTIME_URL}/${FORM}/submit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields }),
signal: AbortSignal.timeout(10_000),
});
return response.json();
}
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`);
// Finds the visitor's answer for a path such as contact.email, so the form can show it again.
const answerAt = (answers, path) => path.split(".").reduce((value, part) => (Object.hasOwn(Object(value), part) ? value[part] : ""), answers);
// One input with its answer kept and its first message under it.
function renderField(path, answers, result) {
const message = result.error?.fields?.[path]?.[0]?.message;
const error = message ? `<p class="error">${escapeHtml(message)}</p>` : "";
return `<input name="${escapeHtml(path)}" value="${escapeHtml(answerAt(answers, path))}">${error}`;
}A field inside a group has a path such as contact.email, and one inside a list has guests[0].email, so split the path to find its answer. See Field paths. Show the visitor’s answers again with the messages, escaped, so they only have to fix what’s wrong.
Retry safely
A network failure doesn’t tell you whether the submission arrived. So send one metadata.idempotencyKey per submission, the same on every retry. Formerie then counts and delivers it once, and answers a repeat with DUPLICATE_SUBMISSION, HTTP 409, which means the first one worked.
const RUNTIME_URL = process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com";
// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";
const MAX_ATTEMPTS = 3;
const LONGEST_WAIT = 30;
const TEMPORARY_CODES = new Set(["FORM_MAINTENANCE", "QUEUE_FAILED", "INTERNAL_ERROR"]);
// One attempt. Says what happened, and whether another attempt could help.
async function sendOnce(fields, key) {
let response;
try {
response = await fetch(`${RUNTIME_URL}/${FORM}/submit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields, metadata: { idempotencyKey: key } }),
signal: AbortSignal.timeout(10_000),
});
} catch {
return { outcome: "retry-later", reason: "network", again: true };
}
// Only the short-term limit sends Retry-After. A form's own limit can last an hour, so don't wait for it.
if (response.status === 429) {
const header = response.headers.get("Retry-After") ?? "";
const after = /^\d+$/.test(header) ? Number(header) : null;
return { outcome: "retry-later", reason: "rate-limited", after, again: after !== null && after <= LONGEST_WAIT };
}
// Something in front of Formerie may answer without JSON, so look at the status first.
let result;
try {
result = await response.json();
} catch {
return { outcome: "retry-later", reason: "bad-response", again: [502, 503, 504].includes(response.status) };
}
if (result.ok) {
return { outcome: "accepted", id: result.submissionUid };
}
const { code, message, issues } = result.error;
if (code === "DUPLICATE_SUBMISSION") {
// The first send worked, and only its answer was lost.
return { outcome: "accepted", duplicate: true };
}
if (TEMPORARY_CODES.has(code)) {
return { outcome: "retry-later", reason: code, again: true };
}
if (code === "VALIDATION_FAILED") {
return { outcome: "invalid", code, issues };
}
return { outcome: "rejected", code, message };
}
// One key per submission, the same on every retry. Store it with the answers if you retry later.
async function submitWithRetries(fields, key = crypto.randomUUID()) {
for (let attempt = 1; ; attempt += 1) {
const { again, ...answer } = await sendOnce(fields, key);
if (!again || attempt === MAX_ATTEMPTS) {
return { ...answer, key, attempts: attempt };
}
const backoff = Math.random() * Math.min(250 * 2 ** attempt, 4_000);
await new Promise((resolve) => setTimeout(resolve, Math.max((answer.after ?? 0) * 1_000, backoff)));
}
}| Answer | Retry? |
|---|---|
| No connection, or a timeout | Yes, with the same key |
429 with Retry-After | Yes, after that wait, if it’s short. The short-term limit answers with Retry-After: 60 |
429 with no Retry-After | No. A form’s own limit can last an hour, so ask the visitor to try again later |
FORM_MAINTENANCE, QUEUE_FAILED, INTERNAL_ERROR | Yes, after a short wait |
502, 503 or 504 with no JSON | Yes. Something in front of Formerie answered |
| Any other answer with no JSON | No. Keep the key, and look into it |
DUPLICATE_SUBMISSION | No. Treat it as a success |
VALIDATION_FAILED and any other code | No. The answer won’t change |
- The key must be a non-empty string. Anything else is ignored, and there’s no repeat check.
- A repeat is noticed for 300 seconds by default, under the form’s repeat window. See What the repeat window covers.
- The check comes first, so a key that went through gets
DUPLICATE_SUBMISSIONeven with different answers. Use a new key for each new submission. A key refused for its answers was never accepted, so it can go again once they’re fixed. - A retry after
QUEUE_FAILEDcan store a second copy, which you’d see under Submissions. It’s rare.
Human verification
If your form requires human verification, use your own keys, with your site in the provider’s list of domains and in the form’s allowed websites. The widget adds its token to your form. Forward two things:
- The token, as
verificationToken, taken out of the fields. - The
Originof the visitor’s request, because Formerie compares the token’s domain with it. A server sends none by itself, and the check then fails withTURNSTILE_FAILED.
const RUNTIME_URL = process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com";
// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";
// posted is what your form sent. origin is the Origin header of the visitor's request.
async function submitVerified(posted, origin) {
const { "cf-turnstile-response": verificationToken, ...fields } = posted;
const response = await fetch(`${RUNTIME_URL}/${FORM}/submit`, {
method: "POST",
headers: {
"Content-Type": "application/json",
// Without the visitor's Origin, the provider's hostname can't match, and the check fails.
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ fields, verificationToken }),
signal: AbortSignal.timeout(10_000),
});
return response.json();
}An Origin that isn’t in the form’s allowed websites gets FORBIDDEN_ORIGIN. Only forward it for this: on any other form, it turns on a check your server doesn’t otherwise need. The widget needs JavaScript in the visitor’s browser. See Verification on the server path.
Spam checks
Hosted pages and embeds send this metadata for you. From your server, send it yourself, or those checks are skipped.
const RUNTIME_URL = process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com";
// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";
async function submitWithMetadata(fields, startedAt, visitorId) {
const response = await fetch(`${RUNTIME_URL}/${FORM}/submit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fields,
metadata: {
idempotencyKey: crypto.randomUUID(),
// When the visitor opened the form, for the minimum submit time check.
startedAt: startedAt.toISOString(),
pageUrl: "https://www.example.com/contact",
// Your own visitor ID, for a limit rule with the deviceToken scope.
deviceToken: visitorId,
},
}),
signal: AbortSignal.timeout(10_000),
});
return response.json();
}| Field | What it’s for |
|---|---|
idempotencyKey | The repeat check, as above |
startedAt | When the visitor opened the form, as an ISO 8601 time. Sent sooner than the form’s minimum, 1 second by default, it gets SPAM_REJECTED. Keep the real time from when you showed the form, not the time of this call. One that can’t be read skips the check |
pageUrl | The page the form was sent from. Stored only if it’s an http or https address |
deviceToken | Your own visitor ID, for a limit per visitor, below |
The hidden field check runs when your form sends that field, and links and human verification run on the server either way. See Spam and limits.
Limits from your server
Formerie counts visitors by network address, and from here, that’s your server’s. Every visitor counts as your server.
| Limit | Default | From your server |
|---|---|---|
| Per visitor | 30 submissions every 10 minutes | Shared by all your visitors. Raise it before you launch, or leave it empty for no limit, under Settings, then Submission limits |
| Whole form | 1,000 submissions an hour | The same. On the Free plan, it’s also the most a form can accept |
| Short-term | 120 requests a minute for each form from one address | Can’t be changed. It’s the only one that sends Retry-After. validate counts here too |
Over any of them, you get RATE_LIMITED, HTTP 429. To keep a limit for each visitor, a rule with the deviceToken scope counts by the ID you send, such as a hash of the visitor’s address. The app’s settings don’t offer that rule yet, so talk to us to set one up, and keep the whole-form limit, since anyone calling the form directly can change their ID. See Limits on the server path for the reasoning, and Plans and limits for monthly counts.
The shorter way
FormerieRuntimeClient, from @formerie/client, makes the same call in less code, and validationMessagesByField sorts a refusal’s messages by field. It’s an alpha package, so its API can still change. It doesn’t retry for you, so the key and the loop above still apply.
npm install @formerie/clientimport { FormerieRuntimeClient, validationMessagesByField } from "@formerie/client";
// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";
const [workspace, form] = FORM.split("/");
const formerie = new FormerieRuntimeClient({
endpoint: process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com",
});
// Name the fields you send, so a visitor can't add others. Keep the key across retries of one submission.
async function submitContact({ name, email, message }, key = crypto.randomUUID()) {
return formerie.submit(workspace, form, {
fields: { name, email, message },
metadata: { idempotencyKey: key },
});
}Express, Fastify and Hono
These work through the API, with the sample above in a route: the framework reads the visitor’s form, and your route calls Formerie. Each one was run against a local Formerie backend, with Express 5.3, Fastify 5.12 and Hono 4.13. Each route answers 202 with the submission’s ID, the same status as Formerie with messages by field, or 503 when it can’t reach Formerie. A native <form method="POST"> can post to any of them, which is what makes a form work without JavaScript.
Express
npm install expressimport express from "express";
const RUNTIME_URL = process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com";
// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";
const app = express();
app.use(express.urlencoded({ extended: false }));
app.post("/contact", async (request, response) => {
// Name the fields you send, so a visitor can't add others.
const { name, email, message } = request.body;
try {
const answer = await fetch(`${RUNTIME_URL}/${FORM}/submit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: { name, email, message } }),
signal: AbortSignal.timeout(10_000),
});
const result = await answer.json();
if (result.ok) {
response.status(202).json({ id: result.submissionUid });
} else {
const errors = Object.fromEntries(
Object.entries(result.error.fields ?? {}).map(([path, issues]) => [path, issues.map((issue) => issue.message)]),
);
response.status(answer.status).json({ code: result.error.code, errors });
}
} catch {
// No connection, a timeout, or an answer that isn't JSON. Keep the visitor's answers on screen.
response.status(503).json({ code: "TRY_AGAIN" });
}
});
app.listen(process.env.PORT ?? 3000);Fastify
Fastify needs @fastify/formbody to read a native form.
npm install fastify @fastify/formbodyimport formbody from "@fastify/formbody";
import Fastify from "fastify";
const RUNTIME_URL = process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com";
// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";
const app = Fastify();
await app.register(formbody);
app.post("/contact", async (request, reply) => {
// Name the fields you send, so a visitor can't add others.
const { name, email, message } = request.body;
try {
const answer = await fetch(`${RUNTIME_URL}/${FORM}/submit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: { name, email, message } }),
signal: AbortSignal.timeout(10_000),
});
const result = await answer.json();
if (result.ok) {
return reply.code(202).send({ id: result.submissionUid });
}
const errors = Object.fromEntries(
Object.entries(result.error.fields ?? {}).map(([path, issues]) => [path, issues.map((issue) => issue.message)]),
);
return reply.code(answer.status).send({ code: result.error.code, errors });
} catch {
// No connection, a timeout, or an answer that isn't JSON. Keep the visitor's answers on screen.
return reply.code(503).send({ code: "TRY_AGAIN" });
}
});
await app.listen({ port: Number(process.env.PORT ?? 3000) });Hono
@hono/node-server is what runs Hono on Node. On Cloudflare Workers, leave it out, and export default app. Your code then reaches Formerie from an address shared by Workers, so read Limits on the server path.
npm install hono @hono/node-serverimport { serve } from "@hono/node-server";
import { Hono } from "hono";
const RUNTIME_URL = process.env.FORMERIE_RUNTIME_URL ?? "https://forms.formerie.com";
// Your form. This is the only line that names it.
const FORM = process.env.FORMERIE_FORM ?? "acme/contact";
const app = new Hono();
app.post("/contact", async (context) => {
// Name the fields you send, so a visitor can't add others.
const { name, email, message } = await context.req.parseBody();
try {
const answer = await fetch(`${RUNTIME_URL}/${FORM}/submit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: { name, email, message } }),
signal: AbortSignal.timeout(10_000),
});
const result = await answer.json();
if (result.ok) {
return context.json({ id: result.submissionUid }, 202);
}
const errors = Object.fromEntries(
Object.entries(result.error.fields ?? {}).map(([path, issues]) => [path, issues.map((issue) => issue.message)]),
);
return context.json({ code: result.error.code, errors }, answer.status);
} catch {
// No connection, a timeout, or an answer that isn't JSON. Keep the visitor's answers on screen.
return context.json({ code: "TRY_AGAIN" }, 503);
}
});
serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3000) });Good to know
- Keep
signal: AbortSignal.timeout(10_000)on every call, so a slow answer can’t hold your page. - Keep test keys and answers out of your logs. A
requestIdin an error, or theX-Request-Idheader, is what to log and to quote when you ask for help. - Match on
error.code, never on the message, since a form can reword its messages and a status can cover several codes. Error codes lists them all.
| With | Note |
|---|---|
| Name your fields | Build fields from the names you expect, as every sample here does, rather than from whatever the request holds. A visitor can post any name. |
Origin | fetch in Node sends none unless you add it, which is what you want everywhere but human verification. |
The routes, fields and every error are in the API reference.