Add a quote-request form

Put a quote-request form on a storefront page, from creating and publishing it to showing the buyer a result and forwarding every submission to your CRM or ERP with an Integration Studio workflow.

A contact or quote form is the first thing every shop wants. A "quote request" in this guide means a form submission that your sales team answers, not a quote in the Quotes App (the next section says when to use which). This guide takes one from nothing to a form on a storefront page that records each enquiry, tells the visitor what happened and hands the enquiry to the system your sales team works in. The form is built from a schema, so a new form is data on the platform and not a deploy of your Theme.

This is for the developer wiring the storefront side, together with whoever sets up the Forms App in Cockpit. You need a tenant with the Forms App installed, a storefront Theme that extends the base layer @revenexx/cover (Storefronts); custom-theme and cover-theme are two packages in the storefront layer repo that do, and the guide names which one it means where it matters, and an API key with the forms.* scopes (which scope covers which app). Step 5 also needs Integration Studio.

Environment
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."

Forms App or Quotes App

The platform has two places a "quote request" can land, and they are not interchangeable.

Forms App (this guide)Quotes App
What the buyer sendsFree-form answers: company, contact, a question, an article numberA basket: at least one line with a name and a positive quantity, plus a currency
Who can send itAny visitor, signed in or notA signed-in buyer; the cover storefront builds it from the cart and two saved addresses
What it becomesA submission in an inbox, announced as an eventA numbered quote with a status, priced by your sales staff and accepted by the buyer, then placed as an order
PricesNoneYes, on the quote

This guide builds a Forms App form, because a form that asks "what do you need a price for?" has no positions to copy onto a quote and has to work for a visitor who has no account. When the buyer already has a cart you cannot order as it stands, use the quote path instead: Turn an accepted quote into an order starts with the request call. The two combine: a workflow in step 5 could turn a form that collects an article number and a quantity into a quote request, but this guide does not build that (not yet confirmed end to end, as of 2026-11).

How a submission travels

YOUR ORIGINthe form blockin the visitor's browseryour Theme's submit routeon your own originForms Appstores the submission as a rowIntegration Studio workflowstarts on the eventyour CRM or ERPbundled demo formsnothing is storedPOST /api/forms/{slug}/submitPOST /v1/forms/submissionsrevenexx.forms.form.submittedHTTP RequestformsService: mockvisitoryour server (dashed: demo mode)platform eventyour workflow

Both /api/forms/... routes are on your storefront's origin and ship in @revenexx/cover, so you write no server code for them. The gateway call happens inside them, with the key that never reaches the browser (The BFF pattern).

1. Create the form and publish it

A form is a name, a slug and a FormKit schema: a flat array of nodes. A node with a name collects one value and writes it under that name in the submission's data. A node with $el is plain content, such as a paragraph of text, and collects nothing.

Create it with POST /v1/forms; copy the id from the response, you need it as <FORM_ID> in the publish call below. Only name and slug are required. The slug is what the storefront looks the form up by, so choose it as carefully as a URL: lower-case letters, digits and inner hyphens, unique per tenant.

Create the form
curl -s -X POST https://api.revenexx.com/v1/forms \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Quote request",
    "slug": "quote-request",
    "lead_stage": "lead",
    "settings": {
      "submit_label": "Request a quote",
      "success_message": "Thanks. We answer quote requests within one working day.",
      "notify_email": "sales@example.com"
    },
    "definition": [
      { "$el": "p", "children": "Tell us what you need a price for." },
      { "$formkit": "text", "name": "company", "label": "Company", "validation": "required" },
      { "$formkit": "text", "name": "name", "label": "Your name", "validation": "required" },
      { "$formkit": "email", "name": "email", "label": "Email", "validation": "required|email" },
      { "$formkit": "tel", "name": "phone", "label": "Phone" },
      { "$formkit": "text", "name": "sku", "label": "Article number" },
      { "$formkit": "number", "name": "quantity", "label": "Quantity", "min": 1 },
      { "$formkit": "textarea", "name": "message", "label": "What do you need a price for?", "rows": 4, "validation": "required" },
      { "$formkit": "checkbox", "name": "privacy", "label": "I agree that my details are stored, saved as a contact and passed to our sales systems to answer this request.", "validation": "accepted" }
    ]
  }' | jq '{id, slug, status}'
Response
{
  "id": "<FORM_ID>",
  "slug": "quote-request",
  "status": "draft"
}

Four things in that body are worth knowing:

  • Input types are FormKit's. text, email, tel, textarea, number, select, checkbox, radio and date all work, and so do group and list. The storefront adds a datepicker input and the validation rules zip, companyName and phoneNumber. A $rxStep marker splits the array into the steps of a wizard. Nothing validates an input type when you save, so a misspelled one fails when the form renders, not on the call.
  • A checkbox with validation: accepted is shown as a box the visitor must tick before the form sends. Use it for a privacy statement. A checkbox that signs a visitor up for a mailing list is a different field with a consent object, and the platform refuses to save it without a subscription type and an email or phone input on the form, so leave it out of a plain quote request.
  • lead_stage files the sender in the customers App when the form carries an email or phone: a person not known yet is created as a contact at that stage, and anyone further along stays where they are. null files no contact. This is what the platform does on its own; set null if your CRM is the only system that should create the person.
  • A form is capped at 60 named inputs by default (the tenant setting Maximum fields per form); a longer definition is a 422 naming both numbers.

The form is born a draft, and a draft renders nothing. Publish it by moving its status:

Publish
curl -s -X PUT "https://api.revenexx.com/v1/forms/<FORM_ID>" \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "live"}' | jq '{slug, status}'
Response
{
  "slug": "quote-request",
  "status": "live"
}

Until then your Theme's GET /api/forms/quote-request answers 404, and a submission straight to the API is refused with 422 and reason: form_not_live. If the form 404s, check the status before the slug. Editors can build and publish the same form in Cockpit's Form Builder; the API is the same route.

2. Place the form on a page

How you place it depends on the layer your Theme extends.

A Theme on @revenexx/cover-theme has a form block. An editor drops it into a content section in Cockpit and picks the form from a list of the tenant's live forms, so nothing is typed and nothing is a deploy. See the block catalog and Forms.

Set formsService to api first. The picker lists the tenant's live forms, but the routes that render and record a form still default to the bundled demo forms, and cover-theme does not switch them in production (as of 2026-11). Without the setting, a form you picked shows "This form is not published". Set it in your Theme's app/app.config.ts:
app/app.config.ts
export default defineAppConfig({
  formsService: "api",
});

Whatever you place, the words around the form come from your Theme's translation files: add forms.success (the thank-you when the form has no success_message) and forms.submit (the button label when it has no submit_label) for every language you serve. The rest of the forms.* keys already exist in the layer.

In local development the picker does not list your new form. On a dev server it shows two bundled sample forms (contact, newsletter), not what is on the tenant, so a form you just created is missing from it (as of 2026-11). On a deployed Theme the picker reads the tenant's live forms. To check your own form locally, give the page the slug directly, as in the next paragraph.

A Theme on @revenexx/cover only, such as the custom-theme package, has no blocks, but the layer ships two components, and custom-theme already sets formsService to api. RevenexxForm renders a form by slug. FormPage wraps it in a page with a title, an intro and a card, and the starting point already uses it for /contact:

app/pages/quote-request.vue
<script setup lang="ts">
const { t } = useI18n();
</script>

<template>
  <FormPage
    slug="quote-request"
    :title="t('forms.quoteRequest.title')"
    :intro="t('forms.quoteRequest.intro')"
    :context="{ sku: $route.query.sku }"
  />
</template>

context pre-fills fields by name, which is how a "Request a quote" link on a product page can arrive with the article number already in the form: link to /quote-request?sku=<SKU>. Add the two forms.quoteRequest.* strings to your Theme's translation files.

custom-theme also ships a /price-request page for a form with the slug price-request, whose article field is filled from ?product= or ?sku= in the link. If your quote request is about one article, create your form under that slug and reuse the page instead of writing a new one: mark the article node with "rxKind": "product" so the layer pre-fills it. Put <RevenexxForm slug="quote-request" /> straight into any other page if you do not want the page shell.

3. Check the routes, and use mock mode on purpose

Two routes carry everything, both on your storefront's own origin:

RouteWhat it does
GET /api/forms/{slug}The live form: name, slug, the localized definition, settings (submit_label, success_message) and honeypot
POST /api/forms/{slug}/submitRecords a submission; the body is { "data": { … }, "source": "/quote-request" }

A third, GET /api/forms, lists the live forms by slug and name, so a page can offer a "Request a quote" link only when the form exists. It answers an empty list rather than an error when the Forms App is unreachable.

Check the first one before you look at the page. It answers from the tenant, so with a running storefront:

Read the form through your Theme
curl -s http://localhost:3000/api/forms/quote-request | jq '{slug, settings, honeypot, fields: [.definition[].name]}'

Mock mode is a switch with a trap. The cover layer reads each domain from its own service setting, and formsService is mock in the layer's defaults. custom-theme sets it to api; cover-theme does not, and neither does a Theme of your own unless you do (step 2). In demo mode the routes serve four bundled demo forms (contact, price-request, callback and widerruf, written in German with an English overlay), record nothing, and answer 404 for any other slug, including yours. The symptom is a form that exists on the tenant and shows "This form is not published". The block picker's local-development list (contact, newsletter) is a separate sample list and does not match these four. In the demo shop a dev panel row, Authored Forms, flips between its mock entry (labelled "Mock (3 Demo-Formulare)") and Live (Public API) through the cover-dev-mode cookie. Mock mode is the right choice for working on the look of a page without credentials; it proves nothing about your form.

Everything that can go wrong in reading a form looks the same on the page: the component swallows the error and shows "This form is not published". Call the route above to see the real status.

4. Handle the submission

The form validates in the browser with FormKit, sends, and then shows one of three results. You do not write this logic, but you do decide what the visitor reads.

OutcomeWhat the route answersWhat the visitor sees
Recorded200 { "ok": true, "outcome": "stored", "success_message": "…" }The form's success_message, or the Theme's own "Thank you" when it is empty
Caught as spam and kept (flag)200 { "ok": true, "outcome": "stored", … } against a live tenant, the same answer as a real enquiry; "outcome": "spam" appears only in mock modeThe same thank-you: a trap that announces itself is no trap
Caught as spam and refused200 { "ok": false, "outcome": "rejected", "success_message": null }An error that says the message did not arrive
The request failed400 Invalid submission when data is not an object, 404 Form not found when the form is not live, 401, 409 and 429 passed through from the gateway, and 500 for everything else, a missing forms.* scope (403), a 422 or a 502 included"Something went wrong. Please try again."

Four details decide how this behaves in practice:

  • The success text and the button label belong to the form. Set success_message and submit_label in the form's settings, as in step 1, and do not hard-code them. They are only the form's own words in its default_locale; a visitor reading another language gets the Theme's translated strings instead, so write the Theme's forms.success and forms.submit keys for each language you serve.
  • A multi-step form can end on a thank-you page. A $rxStep marker with "kind": "thankyou" marks a content-only last step, which the storefront shows above the success message after sending.
  • The route checks less than the browser does. It drops any key the form does not declare, but it does not check that a required field is present, so required fields are not enforced on the platform: treat data as untrusted and check what you rely on in the workflow in step 5.
  • Every failure after the browser's own check is the same sentence. The component shows one generic error for a 400, a 404, a 429 and a 500, so a key without the forms.* scopes looks like a server fault. To tell them apart, watch the network tab, or log the status in your Theme's server route. Do not log the submission body, it is personal data (Logs).

Spam protection is a honeypot, and it ships switched off. There is no captcha and no form-specific rate limit (as of 2026-11); the only limit is the gateway's per-tenant budget, which answers 429 (Rate limits). The honeypot is a decoy field the storefront renders hidden: a person never fills it, a bot fills everything. Turn it on in Cockpit under the Forms App's settings by naming a decoy field, Honeypot field name (for example contact_reference, a name that is not one of your form's fields). Your Theme then renders it for you.

The platform strips the decoy before it stores the answers, as the API reference for POST /v1/forms/submissions says, so it never reaches your CRM. What to do with a caught submission chooses between the two outcomes in the table: the default, flag, keeps the row with status spam so a false positive can be recovered from the inbox; reject refuses it and stores nothing. The settings are read per market (Settings).

Notifications are not sent by the Forms App. It only works out who should be told, once, when the row is written: the form's own notify_email, else the tenant's Fallback notification recipient. The address is stored on the submission as metadata.notify_email, with metadata.notify_source saying which of the two won. Delivering the mail is step 5.

5. Forward submissions to your CRM or ERP

Every stored submission is announced as an event with the topic revenexx.forms.form.submitted, with the submission row as its payload. A refused submission has no row and so no event. Do the forwarding in an Integration Studio workflow rather than from your Theme's submit route: no server of yours has to be up, a failure lands on a run you can read and replay, and a form stays a data-collection surface.

Read the topic and a real payload from your tenant's catalog first. The forms event declares no required fields in the event catalog, so the sample there is your reference, not a table on this page. The catalog has no documented scope yet (as of 2026-10), so use a development key with *:

Read the topic and the sample
curl -s https://api.revenexx.com/v1/events/catalog \
  -H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
  -H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
  | jq '.emits[] | select(.app == "forms" and .name == "form.submitted") | {topic, sample}'

In Cockpit, open Integration Studio → Workflows → New and add an Event trigger with the subject revenexx.forms.form.submitted. The trigger drops a repeat delivery of the same event, so one submission starts one run (Event triggers). Then add these nodes:

  1. If condition: continue only when the submission's status is not spam. Under the default policy a caught submission is stored as spam and still announced, so without this a bot reaches your CRM.
  2. If condition on the fields you rely on, such as email being present. The route does not check required fields, as step 4 says.
  3. HTTP Request to your CRM's lead or enquiry endpoint, mapped from the submission. Set the node to route a failure to an error branch instead of failing silently (Failure handling).
  4. Send Email to the sales desk, using metadata.notify_email as the recipient, with an SMTP credential in Integration Studio → Credentials.

The event's own fields sit under data, and the submission row is that payload. The answers the visitor typed are the row's data object, so they sit one level deeper. Build the expressions with the picker against the sample from the catalog; the shape below is what the platform's contract describes and was not yet checked against a live run of this form (as of 2026-11):

ExpressionValue
${{ trigger.payload.data.id }}The submission's id. Send it to the CRM as the external reference, so a replay updates instead of duplicating
${{ trigger.payload.data.status }}new, or spam when the honeypot caught it
${{ trigger.payload.data.data.email }}The visitor's answer for the field named email
${{ trigger.payload.data.data.message }}Their answer for message; every key is the name of a node in your definition
${{ trigger.payload.data.contact_id }}The customers contact the sender was filed under, or empty when none was
${{ trigger.payload.data.source }}The path of the page the form was sent from, such as /quote-request. page_url stays empty, because the storefront does not send it
${{ trigger.payload.data.metadata.notify_email }}The resolved recipient, or empty when neither the form nor the tenant named one
${{ trigger.payload.id }}The event's id, not the submission's

Values keep the type of their input: a number field arrives as a number, a single checkbox as true or false, a multi-select as an array. Treat every value as end-user data: send it only to the systems that need it and keep it out of run logs.

A form can also call a URL by itself. A form's settings.actions can hold a webhook (POST of { form, source, data }, 8 seconds, the answer is not shown to the visitor) or an entity write, run by your storefront's server right after a successful send. It runs for a submission the honeypot caught and kept as spam too, because the live route cannot tell the two apart. It is best effort and only runs for a storefront on @revenexx/cover, so a failure leaves nothing to replay. Use a workflow for anything you cannot afford to lose.

A submission can also be read, triaged and exported through the API (GET /v1/forms/submissions, PUT /v1/forms/submissions/{id}) for work that is not event-driven; see Forms.

Troubleshooting

What you seeWhy
"This form is not published" on the pageThe form is a draft or archived, the slug does not match, or the Theme is in mock mode and the slug is not one of the demo forms. Call GET /api/forms/<slug> and read the status
422 with reason: form_not_live on a direct call to POST /v1/forms/submissionsThe form is not live. Publish it (step 1)
409 when you create the formThe slug is already taken in this tenant, often by a form nobody opened
A new form is missing from the block pickerOnly live forms are listed; on a local dev server the picker shows two bundled sample forms (step 2)
Every send fails with the generic errorOpen the network tab: the status of POST /api/forms/<slug>/submit is the real answer. 429 is the per-tenant limit
A real enquiry shows as spamThe honeypot caught a field a browser auto-filled; the row is kept under flag, so move it back with PUT /v1/forms/submissions/{id} and {"status": "new"}
A submission is in the inbox but the workflow did not startThe subject must be the four-segment topic revenexx.forms.form.submitted; form.submitted never matches

Where to go next

Start with Forms: the delivery route, the inbox, triage, retention and deleting a form, which you need once the form is live.

Was this page helpful?